diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml index 604c1fe195..41376c7c73 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.i18n.yaml @@ -30,8 +30,8 @@ en: 743f2dceda3072aa zh: f4d53ddc13a4127e /agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/the-client-store-separates-cached-and-sequenced-rows: - en: 8a4e9e9ac2ada06f - zh: b5884535cd934f72 + en: dd1cc7ac96a5a25d + zh: bdea08d20f166e65 /agent-note-projection-cache-read-only-face-matches-the-lifecycle-identity-the-client-store-separates-cached-and-sequenced-rows/decision/what-stays-untouched: en: 1cefba02bbfb2647 zh: 5a96942565ab9d72 diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md index 5ef27cb076..f0235d8040 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.md @@ -97,7 +97,7 @@ Writer classification: | the `projections` block of an `api-session/added` summary (`manager.handleSessionAdded`) | by the block's `kind`; the summary comes from a live session, so in practice `sequenced` | | the history first page's `projections` (`projections.seed` in `session.ts`) | sequenced | | the control baseline, live sessions only (`manager.replaceControlBaseline`) | sequenced | -| the `session.projections` result of `refreshProjections` (a body observation) | sequenced | +| the `session.projections` result of `refreshProjections` | sequenced for an exact read; cached only when [migration is required](2026-09-29-explicit-session-history-preparation.md) | | push frames (the `projection` frame handled by `manager`) | sequenced | | the `title` after a successful rename (`session.ts`) | sequenced | diff --git a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md index e69db37e7e..326e6f028b 100644 --- a/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md @@ -97,7 +97,7 @@ seq 比较只在同一条 Host 连接内发生:`handleConnected` 先整表 `cl | `api-session/added` 摘要的 `projections` block(`manager.handleSessionAdded`) | 按 block 的 `kind`;该摘要来自活会话,实际为 `sequenced` | | history 首页 `projections`(`session.ts` 的 `projections.seed`) | sequenced | | control 基线,仅活会话(`manager.replaceControlBaseline`) | sequenced | -| `refreshProjections` 的 `session.projections` 结果(正文观察) | sequenced | +| `refreshProjections` 的 `session.projections` 结果 | 准确读取为 sequenced;仅[需要迁移](2026-09-29-explicit-session-history-preparation.zh.md)时为 cached | | 推送 frame(`manager` 处理 `projection` frame) | sequenced | | rename 成功后的 `title`(`session.ts`) | sequenced | diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml new file mode 100644 index 0000000000..ddd6d80912 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-29-explicit-session-history-preparation.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md +/agent-note-open-the-session-when-migration-is-required: + en: d409772933407317 + zh: ac258a6d1743965f +/agent-note-open-the-session-when-migration-is-required/problem: + en: ab63c8127d68d294 + zh: d3f99489c96ff1d4 +/agent-note-open-the-session-when-migration-is-required/decision: + en: 32627d744a6e7410 + zh: 2836a95ee5390111 +/agent-note-open-the-session-when-migration-is-required/alternatives-considered: + en: baea781018a49252 + zh: bcc452029bf229fd +/agent-note-open-the-session-when-migration-is-required/consequences: + en: 979eebb53a6bf711 + zh: 013743b0b7a000ce diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md new file mode 100644 index 0000000000..84b30b6cb4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.md @@ -0,0 +1,43 @@ +# Agent Note: Open the Session when migration is required + +Status: implemented + +English | [中文](2026-09-29-explicit-session-history-preparation.zh.md) + +## Problem + +Expanding a subagent catalog or forking from the sidebar can trigger an old Session's format migration. + +## Decision + +The WorkspaceBrowser row-menu fork request carries `allowMigration: false`, which the Host handles by checking `stat` format information before calling the original fork flow. Only a source format requiring migration is refused, with an app-wide Toast offering to open the original Session. That action only navigates and does not retry fork. Neither an absent live Session nor unopened Client history justifies refusal. + +Other fork callers omit that option and allow automatic migration. + +`session.projections` also checks the stored format first; current-format cold history still uses the original observation flow for exact projections. Success returns `kind: sequenced` and its real `asOfSeq`; required migration returns `kind: migration-required` hints. The Client retains that read state, and cached values cannot replace sequenced values published by history or control. + +The `formatStatus` returned by `stat` carries the migration library's existing classification; `header` remains the current logical format. It describes the stored format, not migration-task progress or prepared in-memory history. + +When migration is required and `subagentCatalog` is absent, Web marks the Session's row “Migration required” and keeps its history-opening actions without descendant expansion or changing its subagent mode. Opening the Session supplies its catalog through history and control projections. Available catalogs remain usable, including empty catalogs. Session-list lineage resolves navigation addresses, not missing catalog membership. Ordinary loading, cancellation, and storage errors do not become this migration branch. + +Create/adopt, full-text search, and ordinary history opening allow automatic migration. + +This decision adds a migration exception to [catalog projection reads](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md). Exact reads and leases in [Session observations](2026-08-25-session-observations-and-projection-owned-client-state.md), and identity checks and cached/sequenced ordering in [projection-cache reads](2026-09-19-projection-cache-listing-identity-and-cached-rows.md), still apply. + +## Alternatives considered + +**Treat every cold Session as needing migration.** Residency and stored format are different; this would reject otherwise valid forks and discard catalog information available on disk. + +**Globally forbid forks that automatically migrate on the Host.** The sidebar's interaction choice does not govern other callers, so its request explicitly carries the restriction. + +**Automatically open and retry the sidebar fork.** Opening is an explicit user action, and fork does not run automatically after migration. + +**Derive catalogs from Session-list parent ids.** This adds a second membership derivation; explicit Session opening obtains the catalog. + +**Give cached hints `asOfSeq: -1`.** Hints have no event cut in current history and cannot participate in sequence comparisons. + +## Consequences + +Sessions requiring migration use the existing opening flow to prepare history; Sessions that need no migration remain cold-readable and forkable. Catalog hints may lag the log and yield to subsequent exact projections. + +A separate follow-redirection proposal discusses future single-Session migration progress; this decision does not implement that flow. diff --git a/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md new file mode 100644 index 0000000000..31925f7a9f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 需要迁移时引导打开会话 + +Status: implemented + +[English](2026-09-29-explicit-session-history-preparation.md) | 中文 + +## 问题 + +展开 subagent 目录或从侧栏 fork 可能触发旧会话的格式迁移。 + +## 决定 + +WorkspaceBrowser 行菜单的 fork 请求携带 `allowMigration: false`,由 Host 在调用原 fork 流程前检查 `stat` 的格式信息。只有源格式需要迁移时才拒绝,并显示带“打开原会话”操作的应用级 Toast。该操作只导航,不自动重发 fork。没有 live Session 或 Client 尚未打开历史不构成拒绝理由。 + +其他 fork 调用者省略该选项,允许自动迁移。 + +`session.projections` 同样先检查存储格式,当前格式冷历史继续通过原观察流程读取准确投影。成功时返回 `kind: sequenced` 和真实 `asOfSeq`;需要迁移时返回 `kind: migration-required` 的缓存提示。Client 保留这一读取状态,缓存值不能覆盖 history 或 control 已发布的有序值。 + +`stat` 返回的 `formatStatus` 携带迁移库已有的分类;`header` 仍是当前逻辑格式。它描述存储格式,不描述迁移任务进度或内存准备缓存。 + +在需要迁移且缺少 `subagentCatalog` 时,Web 将该会话行标为“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode。打开会话后,历史与 control 投影提供其目录。已有目录仍可使用,包括空目录。会话列表的谱系用于解析导航地址,不用于补齐缺失的目录成员。普通加载、取消和存储错误不转换成这一迁移分支。 + +create/adopt、全文搜索和普通历史打开允许自动迁移。 + +本决策为[目录投影查询](../../archived/simplification/2026-09-08-web-subagent-catalog-projections.md)增加迁移例外。[会话观察](2026-08-25-session-observations-and-projection-owned-client-state.zh.md)的准确读取和租约,以及[投影缓存读取](2026-09-19-projection-cache-listing-identity-and-cached-rows.zh.md)的身份校验与缓存/有序分层继续适用。 + +## 考虑过的替代方案 + +**把所有冷会话都视作需要迁移。** 是否驻留与存储格式不同;这样会拒绝可以正常完成的 fork,并丢失能够从磁盘读取的目录信息。 + +**Host 全局禁止自动迁移的 fork。** 侧栏的交互选择不适用于其他调用者,因此限制由该请求显式携带。 + +**侧栏 fork 自动打开并重试。** 打开会话由用户明确选择,fork 不在迁移后自动执行。 + +**按会话列表中的父 id 派生目录。** 这需要第二套成员派生逻辑;显式打开会话即可获取目录。 + +**让缓存提示携带 `asOfSeq: -1`。** 提示没有当前历史的事件切点,不能参与序号比较。 + +## 影响 + +需要迁移的会话通过现有打开流程完成准备;无需迁移的会话仍可冷读和 fork。目录的缓存提示可能落后于日志,后续准确投影替换它们。 + +后续单会话迁移进度由独立的 follow 重定向提案讨论,本决策不实现该流程。 diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml new file mode 100644 index 0000000000..29a80e39fa --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.i18n.yaml @@ -0,0 +1,25 @@ +# Bilingual-pair consistency record for 2026-09-29-session-migration-follow-redirect.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md +/agent-note-present-single-session-migration-through-follow-redirection: + en: 294f1872bbef5f59 + zh: c4bf2852374fbba4 +/agent-note-present-single-session-migration-through-follow-redirection/problem: + en: 8d019bda1e854df3 + zh: 49cbd8b8d869e406 +/agent-note-present-single-session-migration-through-follow-redirection/proposal: + en: b5d43cb845d165ed + zh: c5b3aa8eb7799134 +/agent-note-present-single-session-migration-through-follow-redirection/proposal/completion-milestone-remains-open: + en: 2281727adab80d51 + zh: 4795be5613b37ede +/agent-note-present-single-session-migration-through-follow-redirection/alternatives-considered: + en: bfc5d2325e710601 + zh: 4f285a9f317cd6af +/agent-note-present-single-session-migration-through-follow-redirection/acceptance-criteria: + en: e5d810300cec5f99 + zh: 7bcef7a5ef99501e +/agent-note-present-single-session-migration-through-follow-redirection/risks: + en: 1704cb15dc3c0b5e + zh: cc4576d6e47c3b22 diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md new file mode 100644 index 0000000000..282722f2eb --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.md @@ -0,0 +1,43 @@ +# Agent Note: Present single-Session migration through follow redirection + +Status: proposed + +English | [中文](2026-09-29-session-migration-follow-redirect.zh.md) + +## Problem + +Opening historical Sessions can wait for lengthy format conversion. Ordinary loading cannot explain the current phase or show migration failure; placing progress inside history events would contaminate the model log and change journal sequence semantics. + +## Proposal + +Add a typed business redirect to single-Session history reads: page or follow returns a migration reference when preparation is required, the Client follows that migration for its current phase and subsequent progress, then returns to the original history read after success. “302” denotes a business result, not a browser HTTP redirect, and accepts no arbitrary URL. + +The Session object layer owns redirection, waiting, failure, and reopening. React must observe an explicit migration state and render a dedicated component; failure retains its cause and retry action. Migration success returns only to history loading, and a valid snapshot establishes history readiness. Paging and reconnect retain existing history and drafts. + +The actual persistence preparation owner retains migration ownership; multiple waiters share work and late joiners receive current state immediately. Closing one view releases only its waiter. Started durable publication retains its run-to-settlement policy. Progress enters neither the Session log nor `session.control` for this flow. + +Create/adopt and full-text search keep automatic processing; fork and catalog reads use the [migration prerequisite](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.md). + +### Completion milestone remains open + +Read-open currently produces validated in-memory history; write-open encodes, verifies, and publishes a new generation. If redirection waits only for read preparation, its reference must retain the prepared result until the original follow takes ownership rather than relying on an evictable short-lived cache. Waiting for publication instead adds write-permission and publication-time requirements to opening old history. Implementation must choose this behavior rather than using an ambiguous done state for both milestones. + +## Alternatives considered + +**Push migration progress through global control.** One history-opening operation can own its waiting, failure, and retry without independent global progress synchronization. + +**Insert progress before the ordinary history journal's opening frame.** The generic journal remains snapshot-first; the Session adapter consumes redirection and migration frames, forwarding only ordinary history to the journal. + +**Automatically replay every RPC that encounters migration.** Commands with committed effects cannot be replayed safely. This proposal covers recoverable history reads rather than a general command-retry platform. + +## Acceptance criteria + +- The Client shows actual migration phases before receiving the history snapshot; failure remains visible without an automatic retry loop. +- Concurrent joiners, independent waiter cancellation, and reconnect observe the same operation's current state; an old attempt cannot overwrite a new one. +- The resumed history read cannot restart the same migration after cache eviction; cancellation never rolls back a published generation. +- Unknown totals produce no fabricated percentages. Unrelated lightweight requests and progress frames receive execution time during migration, with responsiveness measured separately. +- Create/search, fork/catalog policies, and existing Session data retain their respective guarantees. + +## Risks + +Redirection spans two requests and needs explicit retention and cancellation ownership. Progress alone does not remove synchronous Host parsing and validation stalls; scheduling changes require measurements. The choice between read preparation and write publication remains an implementation prerequisite. diff --git a/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md new file mode 100644 index 0000000000..5dbc213167 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-29-session-migration-follow-redirect.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 单会话迁移通过 follow 重定向呈现 + +Status: proposed + +[English](2026-09-29-session-migration-follow-redirect.md) | 中文 + +## 问题 + +打开历史会话可能等待较长的格式转换。普通 loading 无法解释当前阶段或显示迁移失败;把进度放入历史事件又会污染模型日志,并改变历史流的序号语义。 + +## 提案 + +对单会话历史读取增加 typed 业务重定向:page 或 follow 在需要迁移时返回迁移引用,Client 转到迁移 follow,接收当前阶段和后续进度;成功后回到原历史读取。这里的“302”是业务结果,不是浏览器 HTTP 跳转,也不接受任意 URL。 + +Session 对象层编排跳转、等待、失败和重新读取。React 必须看到迁移中的明确状态并显示专门组件;失败保留原因和重试入口。迁移成功只回到历史加载态,收到有效 snapshot 才表示历史就绪。分页或重连已经拥有的历史与草稿保留。 + +迁移操作仍由实际持久化准备入口拥有;多个等待者共享任务,后加入者立即取得当前状态。关闭一个视图只释放其等待,不取消其他等待者。已开始的 durable publication 遵守既有运行至结算规则。进度不写入 Session log,不使用 `session.control` 作为本流程的进度通道。 + +create/adopt 和全文搜索保持自动处理;fork 和目录读取采用[迁移前置检查](../../implemented/architecture/2026-09-29-explicit-session-history-preparation.zh.md)。 + +### 完成里程碑仍待确定 + +现有 read-open 只生成经验证的内存结果;write-open 才编码、校验并发布新 generation。若重定向只等待读准备,迁移引用必须将准备结果保留到原 follow 接手,不能依赖可淘汰的短期缓存。若等待完整发布,则打开旧历史会额外要求写权限并等待发布。实现前必须确定这一行为,不以一个模糊的 done 混用两种里程碑。 + +## 考虑过的替代方案 + +**通过全局 control 推送迁移进度。** 单次打开历史的等待、错误与重试更适合由该读取流程拥有,不需要增加独立的全局进度状态同步。 + +**直接在普通 history journal 首帧前插入进度。** 通用 journal 保持 snapshot-first;Session 适配层消费重定向和迁移流,只把正常历史交给 journal。 + +**所有 RPC 遇到迁移都自动重发。** 已执行副作用的命令不能安全重放。此提案限定可安全恢复的历史读取,不扩展成通用命令重试平台。 + +## 验收条件 + +- 迁移等待开始后,Client 在收到历史 snapshot 前显示真实阶段;失败保持可见且不会自动循环重试。 +- 并发加入、单等待者取消和重连均取得同一操作的当前状态;旧尝试的通知不覆盖新尝试。 +- 完成后的历史读取不会因为缓存淘汰重新启动同一迁移;已发布 generation 不因取消而回滚。 +- 未知总量不显示伪造百分比。迁移时无关轻量请求和进度帧仍能得到执行机会,响应性单独测量。 +- create/search、fork/catalog 策略及已有 Session 数据保持各自约定。 + +## 风险 + +重定向跨越两次请求,需要明确保活与取消所有权。迁移进度并不会消除 Host 主线程的同步解析和验证暂停;相关调度调整必须依据实测。只读准备与写入发布的完成定义仍是实施前置选择。 diff --git a/apps/web/package.json b/apps/web/package.json index 6dfc75207f..f44baf5c4c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -46,6 +46,7 @@ "@deepseek-ai/dsh-experimental-webworker-packer": "workspace:*", "@deepseek-ai/dsh-experimental-webworker-runtime": "workspace:*", "@deepseek-ai/dsh-remote-mock": "workspace:*", + "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", "@types/node": "^22.20.1", "@types/react": "~18.3.1", diff --git a/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md new file mode 100644 index 0000000000..0e4f8f018b --- /dev/null +++ b/apps/web/tests/expected/subagent-conversation/migration-required-child.expected.md @@ -0,0 +1,13 @@ +# Before opening the child + +- tree "Subagent sessions": + - treeitem "historical researcher Migration required · not running" [level=1]: + - text: historical researcher Migration required · not running + - button "Open historical researcher in sidebar" + +# After opening the child + +- tree "Subagent sessions": + - treeitem "historical editor Migration required · not running" [level=1]: + - text: historical editor Migration required · not running + - button "Open historical editor in sidebar" diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index c8b9c35b55..2254c9dafe 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -3,16 +3,17 @@ // calls) and pins the settled conversation aria after the footers are // focus-revealed — the surface package jsdom tests cannot substitute for // (docs/testing.md snapshot rule). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' +import { zstdCompressSync } from 'node:zlib' +import type { Browser, Page, Request as BrowserRequest } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, parseSeedFixture, realizeSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { openSettings, newEnglishPage, pinBrowserClock, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' @@ -22,6 +23,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-actio const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork.expected.md') +const UNOPENED_FORK_EXPECTED = join(SNAPSHOT_DIR, 'fork-unopened.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'message-actions-web-e2e' @@ -192,7 +194,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { - scaffold = await launchWebScaffold({}) + scaffold = await launchWebScaffold() + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true }) const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') @@ -222,7 +225,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { }) it.skipIf(MODE === 'record')('enables branch only on the completed transcript tail', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions')) + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-message-actions')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) await groupRow.click() @@ -423,9 +426,83 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await compareOrRefreshGolden(FORK_EXPECTED, tree, MODE) }) + it.skipIf(MODE === 'record')('forks an unopened current-format Session from its sidebar row', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/current-cold-fork')) + const id = SessionId('message-actions-current-cold') + await seedSession(scaffold, completedTailFixture(await readFile(SEED, 'utf8')), id) + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + const row = page.locator(`[data-row-key="session:${id}"]`) + await row.waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + await row.hover() + await row.locator('button[aria-label^="Session actions for "]').click() + await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click() + await expect.poll(() => scaffold.ctx.agents.list().some(agent => agent.session.header.parentSession === id)).toBe(true) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await row.getAttribute('aria-selected')).not.toBe('true') + }) + + it.skipIf(MODE === 'record')('guides only a migration-required sidebar fork to opening its source', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-fork')) + const id = SessionId('message-actions-needs-migration') + const [header, ...events] = realizeSeedFixture(scaffold, await readFile(SEED, 'utf8'), id).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + expect(header?.version).toBe(3) + const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()) + expect(projects).toHaveLength(1) + const directory = join(scaffold.persistenceRoot, projects[0]!.name, id) + const predecessor = join(directory, 'session.v3.jsonl.zstd') + const createdAt = WEB_FIXTURE_TIME - 90_000 + const rows = [{ ...header, id, cwd: scaffold.workspaceCwd, createdAt }, + ...events.map((event, seq) => ({ ...event, seq, time: createdAt + seq + 1 }))] + const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`)))) + await mkdir(directory, { recursive: true }) + await writeFile(predecessor, source) + await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'migration-required' }) + + const warningStart = tripwire.warnings.length + await page.reload({ waitUntil: 'load' }) + await page.getByRole('button', { name: /^Show \d+ more sessions$/ }).click() + const row = page.locator(`[data-row-key="session:${id}"]`) + await row.waitFor() + acknowledgeReloadConnectionLoss(tripwire, warningStart) + const forkRequests: string[] = [] + const trackFork = (request: BrowserRequest): void => { + const path = new URL(request.url()).pathname + if (path === '/api/session/fork') forkRequests.push(path) + } + page.on('request', trackFork) + try { + await row.hover() + await row.locator('button[aria-label^="Session actions for "]').click() + await page.getByRole('menuitem', { name: 'Fork session', exact: true }).click() + const notice = page.getByRole('alert').filter({ hasText: 'Open the original session to complete migration before creating a branch.' }) + await notice.waitFor() + expect(await page.getByRole('menuitem', { name: 'Fork session', exact: true }).count()).toBe(0) + expect(forkRequests).toEqual(['/api/session/fork']) + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd']) + expect(await readFile(predecessor)).toEqual(source) + await compareOrRefreshGolden(UNOPENED_FORK_EXPECTED, + await captureStableAria(page, '[role="alert"]', scaffold.workspaceCwd), MODE) + await notice.getByRole('button', { name: 'Open original session', exact: true }).click() + await expect.poll(() => row.getAttribute('aria-selected')).toBe('true') + await expect.poll(() => scaffold.ctx.agents.get(id)).toBeDefined() + await expect(scaffold.ctx.sessionPersistence.stat(id)).resolves.toMatchObject({ formatStatus: 'current' }) + expect(await readdir(directory)).toContain(`session.v${SESSION_FORMAT_VERSION}.jsonl.zstd`) + expect(await readFile(predecessor)).toEqual(source) + expect(forkRequests).toEqual(['/api/session/fork']) + expect(scaffold.ctx.agents.list().filter(agent => agent.session.header.parentSession === id)).toEqual([]) + } finally { + page.off('request', trackFork) + } + }) + it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'fork-unopened.expected.md', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/skill-invocation-policy.e2e.ts b/apps/web/tests/skill-invocation-policy.e2e.ts index 9723ae3494..bc77481b99 100644 --- a/apps/web/tests/skill-invocation-policy.e2e.ts +++ b/apps/web/tests/skill-invocation-policy.e2e.ts @@ -140,6 +140,7 @@ describe('web e2e: skill invocation policy through the real host', () => { await option.click() const skill = input.locator('[data-composer-text-ref]').filter({ hasText: '/policy-shared' }) const file = input.locator('[data-composer-chip]') + await file.locator('span').first().waitFor({ state: 'visible' }) const draft = await input.textContent() const alignment = await input.evaluate((el) => { const skill = el.querySelector('[data-composer-text-ref]')! diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 6b6cd918b7..49fe63b3d0 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -1,10 +1,11 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { zstdCompressSync } from 'node:zlib' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay' import { SESSION_FORMAT_VERSION, SessionId as sessionId, SessionLogOffset, SessionSeq, @@ -12,10 +13,11 @@ import { } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-agent' import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent' +import { checkpointRecord, projectionCacheDomainSpec } from '@deepseek-ai/dsh-session-projection-cache' import { acknowledgeReloadConnectionLoss, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden, - launchWebScaffold, readPersistedEvents, selectedSessionFixture, watchConsole, + launchWebScaffold, readPersistedEvents, realizeSeedFixture, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts' @@ -32,6 +34,9 @@ const SIDEBAR_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent- const SIDEBAR_CHAT_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/sidebar-chat.expected.md', import.meta.url)) const UNAVAILABLE_GRANDCHILD_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/nested.expected.md', import.meta.url)) const FORK_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/subagent-conversation/fork.expected.md', import.meta.url)) +const MIGRATING_CHILD_EXPECTED = fileURLToPath(new URL( + './expected/subagent-conversation/migration-required-child.expected.md', import.meta.url, +)) const MODE = webSnapshotMode() const LABEL = 'event-sourcing researcher' const ONE_SHOT_LABEL = 'event-sourcing reviewer' @@ -120,6 +125,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = paceMs: 25, }) browser = await chromium.launch() + await mkdir(fileURLToPath(new URL('../../../.artifacts/screenshots/non-migrating-session-reads', import.meta.url)), { recursive: true }) page = await newEnglishPage(browser) page.on('request', (request) => { const path = new URL(request.url()).pathname @@ -320,6 +326,13 @@ describe('web e2e: persisted subagent conversation and human continuation', () = }, ]) } + await waitForCacheRow(scaffold, childHeader) + const cache = scaffold.ctx.storageDomain.get(projectionCacheDomainSpec.name) + if (cache === undefined) throw new Error('projection cache is not open') + const table = cache.table('sessions') + const record = checkpointRecord.parse(table.get(childId)) + const { subagentCatalog: _catalog, ...rows } = record.rows + await table.put(childId, { ...record, rows }) // These two cold fixtures were authored after the page's initial // session.list and intentionally emitted no api-session/added event. Reload // to exercise the restart baseline that discovers their full lineage. @@ -362,8 +375,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await writeComposerDraft(page, input, '') }) - it('expands a persisted grandchild progressively without activating either level', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-tree')) + it('recovers a current-format cold child catalog cache miss without activating either level', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/web-e2e-subagent-tree')) await page.getByRole('button', { name: '2 subagents' }).hover() const catalogTree = page.getByRole('tree', { name: 'Subagent sessions' }) expect(await catalogTree.evaluate((element) => { @@ -379,12 +392,22 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect(await oneShotRow.locator('[data-state="done"]').count()).toBe(1) expect(await oneShotRow.getByText('~6mo 12d', { exact: true }).count()).toBe(1) expect(await oneShotRow.getAttribute('aria-label')).toContain('192d 00h 00m 00s') - await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') + try { + const projections = page.waitForResponse(response => new URL(response.url()).pathname === '/api/session/projections') + await page.getByRole('button', { name: `Expand ${LABEL} descendants` }).click() + await projections + await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) + expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) + } finally { + observe.mockRestore() + } const childRow = page.getByRole('treeitem', { name: new RegExp(LABEL) }) const childLabel = await childRow.getAttribute('aria-label') await page.waitForTimeout(1_100) expect(await childRow.getAttribute('aria-label')).toBe(childLabel) - await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).waitFor({ timeout: 15_000 }) + expect(await page.getByRole('treeitem', { name: new RegExp(NESTED_LABEL) }).textContent()).toContain('continuable') expect(scaffold.ctx.agents.get(childId)).toBeUndefined() expect(scaffold.ctx.agents.get(grandchildId)).toBeUndefined() const snapshot = await captureStableAria( @@ -657,3 +680,143 @@ describe('web e2e: persisted subagent conversation and human continuation', () = await expect.poll(() => scaffold.ctx.agents.get(childId), { timeout: 10_000 }).toBeUndefined() }) }) + +describe('web e2e: migration-required subagent catalog', () => { + const parentId = sessionId('migration-catalog-parent') + const childId = sessionId('migration-catalog-child') + const grandchildId = sessionId('migration-catalog-grandchild') + const childLabel = 'historical researcher' + const grandchildLabel = 'historical editor' + const predecessors: { id: SessionId; directory: string; path: string; source: Buffer }[] = [] + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + if (MODE === 'record') throw new Error('migration-required subagent catalog is a keyless assembled snapshot') + scaffold = await launchWebScaffold() + const fixture = await readFile(BASE_FIXTURE, 'utf8') + const createdAt = 1784998084442 + await seedSession(scaffold, fixture, parentId, undefined, { createdAt }) + const parentHandle = await scaffold.ctx.sessionPersistence.open(parentId, 'write') + try { + const { events } = await parentHandle.read() + const last = events.at(-1) + if (last === undefined) throw new Error('parent seed has no events') + await parentHandle.append([{ + type: 'subagent/catalog', seq: SessionSeq(last.seq + 1), time: last.time + 1, + data: { version: 1, childId, childCreatedAt: createdAt + 1, mode: 'continuable', label: childLabel }, + }]) + } finally { + await parentHandle.close() + } + const projects = (await readdir(scaffold.persistenceRoot, { withFileTypes: true })).filter(entry => entry.isDirectory()) + expect(projects).toHaveLength(1) + const project = projects[0] + if (project === undefined) throw new Error('parent seed has no project directory') + // Keep historical bytes on disk; seeding through the writer would migrate them before the browser opens. + for (const [id, parentSession, label, depth] of [ + [childId, parentId, childLabel, 1], + [grandchildId, childId, grandchildLabel, 2], + ] as const) { + const [header, ...events] = realizeSeedFixture(scaffold, fixture, id).trimEnd().split('\n') + .map((line): Record => JSON.parse(line) as Record) + expect(header?.version).toBe(3) + const body = [ + ...events, + { type: 'subagent/descriptor', data: snapshotSubagentDescriptor({ mode: 'continuable', provider: 'spawn', label }) }, + ...id === childId ? [{ + type: 'subagent/catalog', + data: { version: 0, childId: grandchildId, childCreatedAt: createdAt + 2, mode: 'continuable', label: grandchildLabel }, + }] : [], + ] + const rows = [ + { ...header, id, createdAt: createdAt + depth, cwd: scaffold.workspaceCwd, parentSession, origin: 'subagent', delegationDepth: depth }, + ...body.map((event, seq) => ({ ...event, seq, time: createdAt + depth + seq + 1 })), + ] + const directory = join(scaffold.persistenceRoot, project.name, id) + const path = join(directory, 'session.v3.jsonl.zstd') + const source = Buffer.concat(rows.map(row => zstdCompressSync(Buffer.from(`${JSON.stringify(row)}\n`)))) + await mkdir(directory, { recursive: true }) + await writeFile(path, source) + predecessors.push({ id, directory, path, source }) + } + for (const { id } of predecessors) { + const stored = await scaffold.ctx.sessionPersistence.stat(id) + expect(stored?.formatStatus).toBe('migration-required') + if (stored === undefined) throw new Error(`historical fixture ${id} is missing`) + expect(scaffold.ctx.sessionProjectionCache.cachedSnapshot(stored.header)).toBeUndefined() + } + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'migration-required subagent Web teardown failed') + }) + + it('stops at a migration-required child until its history opens', async () => { + onTestFailed(() => saveFailureShot(page, 'screenshots/non-migrating-session-reads/migration-required-subagent')) + const observe = vi.spyOn(scaffold.ctx.sessionQuery, 'observeSession') + const projections = vi.spyOn(scaffold.ctx.sessionController, 'projections') + const model = vi.spyOn(scaffold.ctx.llm, 'stream') + try { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const workspaceRow = page.getByRole('tree', { name: 'Sessions' }).getByRole('treeitem').first() + await workspaceRow.click() + await page.locator(`[data-row-key="session:${parentId}"]`).click() + await page.getByRole('button', { name: '1 subagent', exact: true }).hover() + const tree = page.getByRole('tree', { name: 'Subagent sessions' }) + const childRow = tree.getByRole('treeitem', { name: new RegExp(childLabel) }) + await childRow.waitFor() + await expect.poll(() => childRow.textContent()).toContain('Migration required') + await expect.poll(() => childRow.getByRole('button', { name: `Expand ${childLabel} descendants`, exact: true }).count()).toBe(0) + expect(await childRow.getAttribute('aria-expanded')).toBeNull() + expect(await childRow.textContent()).toContain('Migration required') + expect(await childRow.textContent()).not.toContain('unknown mode') + expect(await childRow.getByRole('button', { name: `Open ${childLabel} in sidebar`, exact: true }).isEnabled()).toBe(true) + expect(await tree.getByRole('treeitem').count()).toBe(1) + for (const { id, directory, path, source } of predecessors) { + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readdir(directory)).toEqual(['session.v3.jsonl.zstd']) + expect(await readFile(path)).toEqual(source) + } + const before = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd) + expect(observe.mock.calls.some(([id]) => id === childId || id === grandchildId)).toBe(false) + expect(projections).not.toHaveBeenCalled() + + await childRow.click() + await page.getByRole('button', { name: `Switch subagent: ${childLabel}`, exact: true }).waitFor() + await page.getByRole('button', { name: '1 subagent', exact: true }).hover() + const grandchildRow = tree.getByRole('treeitem', { name: new RegExp(grandchildLabel) }) + await grandchildRow.waitFor() + expect(await grandchildRow.textContent()).toContain('Migration required') + expect(await grandchildRow.getAttribute('aria-expanded')).toBeNull() + expect(await grandchildRow.getByRole('button', { name: `Expand ${grandchildLabel} descendants`, exact: true }).count()).toBe(0) + expect(projections).not.toHaveBeenCalled() + expect(observe.mock.calls.some(([id]) => id === childId)).toBe(true) + const after = await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd) + if (MODE === 'refresh') await mkdir(dirname(MIGRATING_CHILD_EXPECTED), { recursive: true }) + await compareOrRefreshGolden(MIGRATING_CHILD_EXPECTED, + `# Before opening the child\n\n${before}\n\n# After opening the child\n\n${after}`, MODE) + for (const { id, path, source } of predecessors) { + expect(scaffold.ctx.agents.get(id)).toBeUndefined() + expect(await readFile(path)).toEqual(source) + } + expect(observe.mock.calls.some(([id]) => id === grandchildId)).toBe(false) + expect(model).not.toHaveBeenCalled() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + projections.mockRestore() + model.mockRestore() + observe.mockRestore() + } + }) +}) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3fa13bf668..2682944228 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -220,7 +220,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 27bd46a229..929766d30c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -222,7 +222,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d070d6fd23..90de48682d 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -22,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 4d62fcfea2..5af2d93b4e 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -24,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:604`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:599`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:53`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 458d219f5a..9204f3cd77 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1299,8 +1299,8 @@ en: 386989934d1bb62e zh: 69b8bc182a5acbd3 /session-persistence-event-catalog/resolved-persistence-types/string: - en: ecebde6d5ef1e880 - zh: 95ffb06f12850eb5 + en: 90d3fd7202322ca4 + zh: 88cce532aed9f6d4 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1782,8 +1782,8 @@ en: 79db83dd2c430faf zh: 173f568241f5ce86 /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: aacb77f1cead58ba - zh: e6ce9dac4bdd07b7 + en: cb5d3fbab4debac6 + zh: 72add55254356149 /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 7de9efa156..5f18bee0d8 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5382,7 +5382,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7341,7 +7341,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 3d75f6ccfb..d8f47a9896 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5384,7 +5384,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7343,7 +7343,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:403`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:408`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 4cf676dbc6..e0920bedf8 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23137,7 +23137,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:398", + "packages/api/session-controller/src/types.ts:403", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -23582,7 +23582,7 @@ "digest": "6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:403" + "packages/api/session-controller/src/types.ts:408" ] }, { diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 7c6b47deb1..3c0c2586a7 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> interface SessionPersistenceSnapshot { /** Detached metadata for one stored session. */ readonly header: SessionHeader + /** Header-only format classification; absent without versioned artifact metadata. */ + readonly formatStatus?: 'current' | 'migration-required' /** Opaque change token; see {@link SessionPersistence.stat}. */ readonly revision: SessionPersistenceRevision /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 2883db7e22..dba0685409 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -311,6 +311,8 @@ type SessionPersistenceRevision = Branded<'SessionPersistenceRevision'> interface SessionPersistenceSnapshot { /** Detached metadata for one stored session. */ readonly header: SessionHeader + /** Header-only format classification; absent without versioned artifact metadata. */ + readonly formatStatus?: 'current' | 'migration-required' /** Opaque change token; see {@link SessionPersistence.stat}. */ readonly revision: SessionPersistenceRevision /** Logical event count, when the backend can provide it cheaply from metadata; otherwise absent. */ diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 1cf3dff325..1b07f1c698 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -24,6 +24,8 @@ interface SessionRecord { live: boolean /** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */ persisted: boolean + /** Current for live Sessions; otherwise the persistence format status when reported. */ + formatStatus?: 'current' | 'migration-required' } ``` diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index 2b8504dfb9..95b9b570af 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -24,6 +24,8 @@ interface SessionRecord { live: boolean /** Whether the active persistence backend currently lists the id, including a created-but-unmaterialized session it already observes. */ persisted: boolean + /** Current for live Sessions; otherwise the persistence format status when reported. */ + formatStatus?: 'current' | 'migration-required' } ``` diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 1dd08630bb..24d4833a81 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -922,10 +922,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ -@Remote('fork') fork(request: SessionForkRequest): Promise +@Remote('fork') async fork(request: SessionForkRequest): Promise /** * Admit one prompt after explicitly resuming its Session. @@ -974,10 +974,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 8085cb8067..d214c4b471 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -926,10 +926,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ -@Remote('fork') fork(request: SessionForkRequest): Promise +@Remote('fork') async fork(request: SessionForkRequest): Promise /** * Admit one prompt after explicitly resuming its Session. @@ -978,10 +978,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote({ mode: 'stream' }) follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 18627b3c99..94dd4e518f 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: ae2715276ec70675 - zh: 0937d1335c21eff2 + en: d26a58a8f2406cd5 + zh: 82135eceb9adb628 /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index d5a7f2a7a7..ca922877f8 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -31,7 +31,9 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` reads one complete baseline through a live-preferred Session observation without activating an Agent. It returns null for a missing Session and serves any registered projection keys. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Initial reads and live projection frames use the same sequence ordering. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. + +List rows carry `formatStatus` when available, so Clients can identify migration-required Sessions without requesting their projections. Host list generation yields between complete rows only when its configured work slice is exhausted; cheap remaining work returns without a forced yield. Request cancellation is checked per row and after each yield and rejects without partial results. Each row is synchronous; the list does not promise one cross-Session snapshot. A Session already queued as cold remains a cached row if it attaches during a yield: Client in-flight mutation replay corrects its availability, and sequenced projections supersede cached hints. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index c6c7f51633..585c4e0363 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -31,7 +31,9 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 通过一次 live-preferred Session observation 读取完整基线,不激活 Agent。Session 不存在时返回 null,并可提供任意已注册的 projection key。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。初始读取与实时 projection 帧使用相同的序号排序规则。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 + +列表行在状态可用时携带 `formatStatus`,Client 无需请求其投影即可识别需要迁移的 Session。 Host 仅在配置的工作时间片耗尽时,于完整条目之间让出事件循环;剩余工作较少时直接返回,不强制让出。每行及每次让出之后检查请求取消,取消时拒绝返回部分结果。每行同步计算;列表不承诺跨 Session 的统一快照。已经进入冷态队列的 Session 若在让出期间接入,仍返回缓存行:Client 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 3f421fe675..b18ac92fbe 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -144,6 +144,7 @@ "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-persistence": "workspace:*", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-session-projection-cache": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts index 076188c1e0..08d6ad2367 100644 --- a/packages/api/session-controller/src/client/contract/sessions.ts +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -91,12 +91,12 @@ export interface ISessions { /** * Resolve an already discovered direct-parent address without opening it. * @param id - possible addressed child id. - * @returns a retained or loaded-catalog address, without retaining a new selection or scope. + * @returns a retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope. */ subagentAddress(id: SessionId): SubagentAddress | undefined /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. * @returns completion of the current or newly started refresh. */ @@ -134,6 +134,8 @@ export interface ISessions { atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void + /** False refuses source migration; omission preserves the Host default. */ + allowMigration?: boolean }): Promise /** * Borrow an already-retained Agent-scoped Context without extending its lifetime. diff --git a/packages/api/session-controller/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts index b0b49311f3..e74cd9dce5 100644 --- a/packages/api/session-controller/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -27,6 +27,8 @@ export interface SessionListEntry { parentSessionId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' + /** Format classification supplied by the Host list. */ + formatStatus?: SessionSummary['formatStatus'] cwd?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index db7236c1b2..9c2df4ba0e 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -61,7 +61,7 @@ export interface SessionListSnapshot { /** Shared projection values and the lifecycle of their explicit baseline read. */ export interface SessionProjectionSnapshot { readonly values: Readonly> - readonly state: 'idle' | 'loading' | 'ready' | 'error' + readonly state: 'idle' | 'loading' | 'ready' | 'migration-required' | 'error' readonly error: RemoteFailure | null } @@ -153,8 +153,9 @@ export class SessionManager { /** * Resolve an address for breadcrumb navigation without retaining transport authority. - * @param sessionId - possible child id in an already-loaded catalog. - * @returns A retained or catalog-derived direct-parent address. + * @param sessionId - possible child id in received Session metadata. + * @returns A retained address, then a catalog address, then an unknown-mode address + * from a listed subagent header; undefined without that evidence. */ subagentAddress(sessionId: SessionId): SubagentAddress | undefined { const retained = this.addresses.get(sessionId) @@ -170,7 +171,13 @@ export class SessionManager { } } } - return undefined + const summary = this.summaries.find(summary => summary.sessionId === sessionId) + if (summary?.origin !== 'subagent' || summary.parentSessionId === undefined) return undefined + return { + parentSessionId: summary.parentSessionId, + childSessionId: sessionId, + mode: 'unknown', + } } // ---- Instance management ---- @@ -332,7 +339,7 @@ export class SessionManager { } /** - * Load a complete projection baseline once per connection; retry unsuccessful reads. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. * @returns completion of the current or newly started read. */ @@ -350,13 +357,26 @@ export class SessionManager { const result = await this.remote.session.projections({ sessionId }, controller.signal) if (controller.signal.aborted) return if (result.ok) { - if (result.value !== null) { - store.seed({ ...result.value, asOfSeq: sessionSeqCursor(result.value.asOfSeq) }) + const value = result.value + if (value !== null) { + switch (value.kind) { + case 'sequenced': + store.seed({ values: value.values, asOfSeq: sessionSeqCursor(value.asOfSeq) }) + break + case 'migration-required': + store.applyCached(value.values) + break + default: + assertNever(value, 'session projections result') + } } else if (store.values() === initialValues) { // A later frame proves existence independently of an earlier missing read. store.clear() } - this.projectionLoads.set(sessionId, { state: 'ready', error: null }) + this.projectionLoads.set(sessionId, { + state: value?.kind === 'migration-required' ? 'migration-required' : 'ready', + error: null, + }) } else { this.projectionLoads.set(sessionId, { state: 'error', error: result.error }) } @@ -526,16 +546,17 @@ export class SessionManager { * preserve it or lower it after an exact cut before the first `turn/start`; * lineage rides parentSessionId. A child published before Workspace * attachment fails is also reconciled into the list. - * @param opts - source session and the optional exact inclusive boundary seq. + * @param opts - source session, optional exact inclusive boundary seq, and permission to start migration. * @returns the fork result (the child session id). */ async fork( - opts: { sessionId: SessionId; atSeq?: SessionSeq }, + opts: { sessionId: SessionId; atSeq?: SessionSeq; allowMigration?: boolean }, ): Promise> { const source = this.summaries.find(s => s.sessionId === opts.sessionId) const result = await this.remote.session.fork({ sessionId: opts.sessionId, ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + ...opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }, }) const childId = result.ok ? result.value.sessionId @@ -774,6 +795,7 @@ export class SessionManager { && prev.blank === entry.blank && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth + && prev.formatStatus === entry.formatStatus && prev.projectionValues === entry.projectionValues ) return prev this.entryCache.set(entry.sessionId, entry) @@ -813,6 +835,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi ...(mutation.kind === 'upsert' ? { agentAvailable: mutation.summary.agentAvailable, running: mutation.summary.running, + ...(mutation.summary.formatStatus === undefined ? {} : { formatStatus: mutation.summary.formatStatus }), } : {}), ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined @@ -823,6 +846,7 @@ function applyMutation(summaries: readonly SessionSummary[], mutation: SessionLi if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId && filled.origin === existing.origin && filled.blank === existing.blank && filled.agentAvailable === existing.agentAvailable && filled.running === existing.running + && filled.formatStatus === existing.formatStatus ) return [...summaries] return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) } diff --git a/packages/api/session-controller/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts index ae36cea19c..c10c81c475 100644 --- a/packages/api/session-controller/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -6,8 +6,8 @@ * (`{ value, seq }`) comes from the connected Host — a follow opening * baseline, a Session Controller `projection` frame, a list block the Host * computed for an attached Session — and merges under **higher seq wins** - * against other sequenced rows. A `cached` row (`{ value }`) comes from the - * session list's view of the persisted checkpoint, carries no comparable + * against other sequenced rows. A `cached` row (`{ value }`) comes from + * a Host view of the persisted checkpoint, carries no comparable * seq, and yields to every sequenced write. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). @@ -166,7 +166,7 @@ export class ProjectionValueStore { } /** - * Fill keys from a session-list block the Host labeled `cached`: a zero-I/O + * Fill keys from a Host block labeled `cached`: a zero-I/O * view of the persisted checkpoint. A cached value lands only where no * sequenced row exists: a connected Session has already answered for such * a key, and the list's view of the persisted checkpoint cannot be newer diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 964105a486..dd86e7cac2 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -21,6 +21,7 @@ import { SessionManager } from './manager.ts' import type { SessionRemotes } from './remotes.ts' import type { SessionListPhase, SessionSearchResultItem, SessionProjectionSnapshot } from './manager.ts' import type { Session } from './session.ts' +import type { SessionListEntry } from './lineage.ts' /** Session list row projected from the host list RPC plus live stream increments. */ export interface SessionSummary { @@ -33,6 +34,8 @@ export interface SessionSummary { parentId?: SessionId /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' + /** Format classification supplied by the Host list, when available. */ + formatStatus?: SessionListEntry['formatStatus'] /** Host running state for `ids` members; a display fallback for other rows. */ running: boolean /** Local ownership counts; Host metadata refreshes cannot overwrite them. */ @@ -332,14 +335,14 @@ export class ClientSessions implements ISessions { * Resolve an already discovered direct-parent address without opening it. * Feature plugins use this to avoid Agent-bound RPCs in persisted child views. * @param id - possible addressed child id. - * @returns A retained or loaded-catalog address, without retaining a new selection or scope. + * @returns A retained, catalog-derived, or unknown-mode listed address, without retaining a new selection or scope. */ subagentAddress(id: SessionId): SubagentAddress | undefined { return this.manager.subagentAddress(id) } /** - * Load all Session projections once per connection; retry an unsuccessful initial read. + * Read projections without starting migration; retry failed or migration-deferred reads. * @param sessionId - Session to inspect without opening its conversation. */ refreshProjections(sessionId: SessionId): Promise { @@ -443,7 +446,7 @@ export class ClientSessions implements ISessions { * seq (a real event seq the caller already knows; a cut inside an open * turn is balanced Host-side with synthetic closers, and omission selects * the latest completed-turn prefix), and whether to increment an - * inherited durable title before resolving. + * inherited durable title before resolving, plus permission to start source migration. * @returns the child session id. * @throws {SessionForkError} with the source id. * @throws {Error} when a requested child-title rename fails after creation. @@ -453,6 +456,7 @@ export class ClientSessions implements ISessions { atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void + allowMigration?: boolean }): Promise { const sourceTitle = opts.increaseTitle ? this.list.getSnapshot().byId[opts.sessionId]?.title @@ -460,6 +464,7 @@ export class ClientSessions implements ISessions { const result = await this.manager.fork({ sessionId: opts.sessionId, ...(opts.atSeq === undefined ? {} : { atSeq: SessionSeq(opts.atSeq) }), + ...(opts.allowMigration === undefined ? {} : { allowMigration: opts.allowMigration }), }) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) this.projectList() @@ -622,6 +627,7 @@ export class ClientSessions implements ISessions { retainedBy: this.retentionSnapshot(entry.sessionId).retainedBy, blank: entry.blank, updatedAt: entry.updatedAt, + formatStatus: entry.formatStatus, ...(entry.projectionValues === undefined ? {} : { projectionValues: entry.projectionValues }), diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 8e38822fbc..0ba5bf8ff0 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -11,6 +11,7 @@ import type {} from '@deepseek-ai/dsh-client-file-upload' import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type {} from '@deepseek-ai/dsh-session-projection-cache' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { @@ -415,11 +416,19 @@ export class SessionController extends TypertRemoteService { * Fork one cold-readable exact event prefix into a new Session. An omitted * boundary selects the latest completed-turn prefix; an open cut receives * synthetic fork closers. - * @param request - source Session and optional exact inclusive event boundary. + * @param request - source Session, optional exact inclusive event boundary, and migration preflight choice. * @returns the new Session identity. */ @Remote('fork') - fork(request: SessionForkRequest): Promise { + async fork(request: SessionForkRequest): Promise { + if (request.allowMigration === false && this.ctx.sessions.get(request.sessionId) === undefined) { + const stored = await this.ctx.get('sessionPersistence')?.stat(request.sessionId) + if (stored?.formatStatus === 'migration-required' && this.ctx.sessions.get(request.sessionId) === undefined) { + throw new RemoteError('session/migration-required', 'Open the source Session to complete migration before forking', { + sessionId: request.sessionId, + }) + } + } return this.commands.fork(request) } @@ -489,10 +498,10 @@ export class SessionController extends TypertRemoteService { } /** - * Read all registered projections without activating an Agent. + * Read exact projections, returning cached hints only when migration is required. * @param request - Session whose current values are required. - * @param signal - cancellation for the Session observation. - * @returns complete baseline, or null when the Session does not exist. + * @param signal - cancellation for the Session read. + * @returns a sequenced baseline, cached hints when migration is deferred, or null when absent. */ @Remote('projections') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise { @@ -501,16 +510,29 @@ export class SessionController extends TypertRemoteService { throw new RemoteError('gateway/bad-request', 'sessionId must not be empty', {}) } try { + signal.throwIfAborted() + const live = this.liveProjections(sessionId) + if (live !== undefined) return live + const stored = await this.ctx.get('sessionPersistence')?.stat(sessionId, { signal }) + signal.throwIfAborted() + const attached = this.liveProjections(sessionId) + if (attached !== undefined) return attached + if (stored === undefined) return null + if (stored.formatStatus === 'migration-required') { + const cached = this.ctx.get('sessionProjectionCache')?.cachedSnapshot(stored.header) + return { kind: 'migration-required', values: { ...cached?.values } as SessionProjectionValues } + } using observation = await this.ctx.sessionQuery.observeSession(sessionId, { signal }) const projections = observation.projections if (projections === undefined) { throw new RemoteError('session/projections-unavailable', 'Session projections are unavailable', {}) } - return { asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } + return { kind: 'sequenced', asOfSeq: projections.asOfSeq, values: projections.values as SessionProjectionValues } } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') return null - if (signal.aborted - || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + return this.liveProjections(sessionId) ?? null + } + if (signal.aborted || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) { throw new RemoteError('gateway/cancelled', 'Session projection read was cancelled', {}, { cause: error }) } if (error instanceof RemoteError) throw error @@ -518,6 +540,15 @@ export class SessionController extends TypertRemoteService { } } + private liveProjections( + sessionId: SessionId, + ): Extract | undefined { + const session = this.ctx.sessions.get(sessionId) + if (session === undefined) return undefined + const snapshot = this.ctx.sessionProjections.snapshot(session) + return { kind: 'sequenced', asOfSeq: snapshot.asOfSeq, values: snapshot.values as SessionProjectionValues } + } + /** * Stream a complete live-control baseline followed by replacement frames. * @param signal - cancellation owned by the Remote stream carrier. diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index c51d119805..d7ef1ad43c 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -8,7 +8,7 @@ import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-session-projection-cache' -import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import { SessionQueryError, type SessionRecord, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { z } from 'zod' import { @@ -116,6 +116,7 @@ export class ApiSessionList { sessionId: session.id, updatedAt: updatedAt(session.header, metadata), agentAvailable: this.ctx.agents.get(session.id)?.session === session, + formatStatus: 'current', running: this.ctx.agents.get(session.id)?.status === 'running', blank: metadata?.blank ?? session.seq === 0, ...listFields(session.header), @@ -133,7 +134,7 @@ export class ApiSessionList { const records = await this.ctx.sessionQuery.listSessions(signal) signal?.throwIfAborted() const items: SessionSummary[] = [] - const cold: SessionHeader[] = [] + const cold: SessionRecord[] = [] let yieldDeadline = performance.now() + this.workSliceMs for (const record of records) { signal?.throwIfAborted() @@ -141,7 +142,7 @@ export class ApiSessionList { if (live !== undefined) { items.push(this.summaryFor(live)) } else if (record.header.cwd !== undefined) { - cold.push(record.header) + cold.push(record) } if (performance.now() >= yieldDeadline) { await scheduler.yield() @@ -149,9 +150,9 @@ export class ApiSessionList { yieldDeadline = performance.now() + this.workSliceMs } } - for (const header of cold) { + for (const record of cold) { signal?.throwIfAborted() - items.push(this.summarizeCold(header)) + items.push(this.summarizeCold(record)) if (performance.now() >= yieldDeadline) { await scheduler.yield() signal?.throwIfAborted() @@ -163,13 +164,14 @@ export class ApiSessionList { return items } - private summarizeCold(header: SessionHeader): SessionSummary { + private summarizeCold({ header, formatStatus }: SessionRecord): SessionSummary { const projections = this.projectionsFor(header, undefined) const metadata = projections?.values.sessionListMetadata return { sessionId: header.id, updatedAt: updatedAt(header, metadata), agentAvailable: false, + ...(formatStatus === undefined ? {} : { formatStatus }), running: false, // A large, metadata-less, or inaccessible cache miss remains unknown and visible. blank: metadata?.blank ?? false, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index a8f0d7e450..3455db1b9f 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -177,6 +177,8 @@ export type QueueAction = export interface SessionSummary { /** Whether this Session currently owns a live Agent. */ readonly agentAvailable: boolean + /** Current for attached Sessions; otherwise the persistence format status when reported. */ + readonly formatStatus?: 'current' | 'migration-required' readonly sessionId: SessionId readonly updatedAt: number readonly running: boolean @@ -224,6 +226,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'session/steer-unavailable': { readonly itemId: MessageId } 'session/title-invalid': { readonly sessionId: SessionId } 'session/fork-unavailable': { readonly sessionId: SessionId } + 'session/migration-required': { readonly sessionId: SessionId } 'subagent/not-found': { readonly parentSessionId: SessionId readonly childSessionId: SessionId @@ -322,6 +325,8 @@ export interface SessionForkRequest { readonly sessionId: SessionId /** Exact inclusive source event seq; omission selects the latest completed-turn prefix. */ readonly atSeq?: number + /** False asks the Host to check for required migration before forking. Defaults to true. */ + readonly allowMigration?: boolean } /** Identity of a newly forked Session. */ @@ -419,8 +424,11 @@ export interface SessionProjectionsRequest { readonly sessionId: SessionId } -/** Complete Session projection baseline; null when the Session does not exist. */ -export type SessionProjectionsValue = SessionProjectionBaseline | null +/** Exact projections carry their event cut; required migration returns sequence-free cache hints. Null means absent. */ +export type SessionProjectionsValue = + | (SessionProjectionBaseline & { readonly kind: 'sequenced' }) + | { readonly kind: 'migration-required'; readonly values: SessionProjectionValues } + | null /** One raw Session event in the Remote journal. */ export interface SessionEventEntry { diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 4da138a716..2097854412 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -4,6 +4,7 @@ */ import { describe, expect, onTestFinished, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' @@ -14,6 +15,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts' import type {} from '@deepseek-ai/dsh-session-title/client' import { SessionManager } from '../src/client/sessions/manager.ts' +import { ClientSessions } from '../src/client/sessions/service.ts' import type { SessionRemotes } from '../src/client/sessions/remotes.ts' import { entries, plainTurn } from './event-script.client.ts' import { FOLLOW, err, followScript, sessionWorld } from './remote/session.client.ts' @@ -647,6 +649,7 @@ describe('list lifecycle', () => { const items = manager.getListSnapshot().items // Cold row: title surfaces straight from the list block — no open, no history. expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('idle') // A cached list block never displaces a sequenced value, whatever watermark its record holds. expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') }) @@ -741,12 +744,83 @@ describe('subagent catalogs', () => { expect(manager.getListSnapshot().items).toEqual([]) }) + it('prefers retained and catalog addresses to header-derived unknown modes', ({ mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S2, { parentSessionId: S1, origin: 'subagent' })) + manager.handleControlFrame({ + type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 0, + value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }], + }) + + expect(manager.subagentAddress(S2)).toEqual({ + parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }) + const retained: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'one-shot' } + manager.resolveTarget(retained) + expect(manager.subagentAddress(S2)).toBe(retained) + expect(remote.session.list).not.toHaveBeenCalled() + expect(remote.session.projections).not.toHaveBeenCalled() + }) + + it.for([ + ['ordinary session', {}], + ['ordinary fork', { parentSessionId: S1 }], + ['parentless subagent', { origin: 'subagent' }], + ] as const)('does not derive a subagent address for an %s', ([, fields], { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + manager.handleSessionAdded(summary(S2, fields)) + + expect(manager.subagentAddress(S2)).toBeUndefined() + expect(manager.subagentAddress(S1)).toBeUndefined() + expect(manager.resolveTarget(S2)).toBe(S2) + expect(manager.get(S2).getSnapshot().subagent).toBeNull() + expect(remote.session.list).not.toHaveBeenCalled() + expect(remote.session.projections).not.toHaveBeenCalled() + }) + + it('retains a header-discovered ancestor through subagent follow without discovery reads', async ({ mock, remote, start }) => { + const grandchild = 'header-grandchild' as SessionId + const descendantAddress: SubagentAddress = { + parentSessionId: S2, childSessionId: grandchild, mode: 'continuable', + } + const ancestorAddress: SubagentAddress = { parentSessionId: S1, childSessionId: S2, mode: 'unknown' } + mock.load(sessionWorld) + remote.session.list.mockResolvedValueOnce(ok({ items: [ + summary(S1), + summary(S2, { parentSessionId: S1, origin: 'subagent' }), + summary(grandchild, { parentSessionId: S2, origin: 'subagent' }), + ] })) + const client = await start() + const ctx = new Context() + onTestFinished(() => ctx.fiber.dispose()) + const sessions = new ClientSessions(ctx, client.ctx.remote) + await sessions.refresh() + using descendant = sessions.retain(descendantAddress, { source: 'mainView' }) + await descendant.ready + + using ancestor = sessions.retain(S2, { source: 'mainView' }) + await ancestor.ready + + expect(mock.log.requests(FOLLOW)).toMatchObject([ + { address: { kind: 'subagent', ...descendantAddress } }, + { address: { kind: 'subagent', ...ancestorAddress } }, + ]) + expect(ancestor.binding.session.getSnapshot()).toMatchObject({ + openState: 'open', subagent: { address: ancestorAddress }, + }) + expect(remote.session.list).toHaveBeenCalledOnce() + expect(remote.session.projections).not.toHaveBeenCalled() + expect(mock.log.requests('session/page')).toEqual([]) + }) + it('keeps a catalog-discovered child address across identity resolution and status frames', async ({ mock, remote, start }) => { remote.session.list.mockImplementation(() => Promise.resolve(ok({ items: [ summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent' }), ] as never[] }))) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker', }] } }))) mock.load(sessionWorld) @@ -859,6 +933,74 @@ describe('subagent catalogs', () => { expect(remote.session.projections.mock.calls.map(([request]) => request)).toEqual([]) }) + it.for(['control', 'follow'] as const)('replaces cached projection hints with the %s baseline', async (source, { mock, remote, start }) => { + mock.load(sessionWorld) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: { + title: 'Cached title', + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }], + } })) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + onTestFinished(() => manager.dispose()) + + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', error: null, + values: { title: 'Cached title', subagentCatalog: [{ id: S2, mode: 'unknown' }] }, + }) + expect(remote.session.follow).not.toHaveBeenCalled() + const session = manager.get(S1) + expect(session.projections.seqOf('subagentCatalog')).toBeUndefined() + const baseline = { asOfSeq: 0, values: { + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' as const, label: 'Loaded child' }], + } } + if (source === 'control') { + manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) + } else { + mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + await session.open() + } + + expect(manager.getListSnapshot().projectionsBySession[S1]?.values).toEqual(baseline.values) + expect(session.projections.seqOf('subagentCatalog')).toBe(0) + expect(session.projections.get('title')).toBeUndefined() + }) + + it.for(['control', 'follow'] as const)('keeps %s projections when a cached read arrives late', async (source, { mock, remote, start }) => { + mock.load(sessionWorld) + const response = Promise.withResolvers>>() + remote.session.projections.mockReturnValueOnce(response.promise) + const client = await start() + const manager = new SessionManager(client.ctx.remote) + const read = manager.refreshProjections(S1) + const baseline = { asOfSeq: 4, values: { + title: 'Loaded title', + subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable' as const, label: 'Loaded child' }], + } } + try { + expect(remote.session.projections).toHaveBeenCalledOnce() + if (source === 'control') { + manager.handleControlFrame({ type: 'baseline', value: { projections: { [S1]: baseline } } }) + } else { + mock.stream(FOLLOW, followScript(ok({ records: [], hasMore: false, projections: baseline }))) + await manager.get(S1).open() + } + response.resolve(ok({ kind: 'migration-required', values: { + title: 'Cached title', subagentCatalog: [{ id: S2, createdAt: 1, mode: 'unknown' }], + } })) + await read + + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', error: null, values: baseline.values, + }) + expect(manager.get(S1).projections.seqOf('subagentCatalog')).toBe(4) + } finally { + response.resolve(ok(null)) + await read + await manager.dispose() + } + }) + it('keeps newer pushed membership when the initial read returns an older baseline', async ({ mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -868,12 +1010,27 @@ describe('subagent catalogs', () => { type: 'projection', sessionId: S1, key: 'subagentCatalog', seq: 5, value: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'pushed' }], }) - response.resolve(ok({ asOfSeq: 3, values: { subagentCatalog: [] } })) + response.resolve(ok({ kind: 'sequenced', asOfSeq: 3, values: { subagentCatalog: [] } })) await read expect(manager.getListSnapshot().projectionsBySession[S1]?.values.subagentCatalog?.[0]?.label).toBe('pushed') expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) }) + it('retries a migration-deferred projection read and accepts the completed catalog', async ({ mock, remote }) => { + const manager = makeManager(mock, remote) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'migration-required', values: {} })) + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'migration-required', values: {}, + }) + remote.session.projections.mockResolvedValueOnce(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) + await manager.refreshProjections(S1) + expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ + state: 'ready', values: { subagentCatalog: [] }, + }) + expect(remote.session.projections).toHaveBeenCalledTimes(2) + }) + it.for([false, true])('keeps live parent availability when an earlier initial read settles (failure: %s)', async (failure, { mock, remote }) => { const response = Promise.withResolvers>>() remote.session.projections.mockImplementation(() => response.promise) @@ -885,7 +1042,7 @@ describe('subagent catalogs', () => { manager.handleSessionStatus(S1, false) response.resolve(failure ? err(new RemoteError('gateway/internal', 'offline', {})) - : ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + : ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) await read expect(child.getSnapshot().subagent?.parentAvailable).toBe(true) expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) @@ -925,7 +1082,7 @@ describe('subagent catalogs', () => { const read = manager.refreshProjections(S1) expect(manager.refreshProjections(S1)).toBe(read) expect(manager.getListSnapshot().projectionsBySession[S1]?.state).toBe('loading') - response.resolve(ok({ asOfSeq: 0, values: { + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: S2, createdAt: 1, mode: 'one-shot' }], title: 'cold parent', } })) @@ -946,7 +1103,7 @@ describe('subagent catalogs', () => { const read = manager.refreshProjections(S1) manager.handleSessionAdded(summary(S2, { origin: 'subagent', parentSessionId: S1 })) manager.handleSessionStatus(S2, true) - response.resolve(ok({ asOfSeq: 0, values: { + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: S2, createdAt: 1, mode: 'continuable', label: 'worker' }], } })) await read @@ -980,7 +1137,7 @@ describe('subagent catalogs', () => { value: [{ id: S2, createdAt: 1, mode: 'one-shot' }] }) await manager.refreshProjections(S1) expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'error', values: { subagentCatalog: [{ id: S2 }] } }) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 2, values: { subagentCatalog: [] } }))) + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 2, values: { subagentCatalog: [] } }))) await manager.refreshProjections(S1) expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ state: 'ready', values: { subagentCatalog: [{ id: S2 }] } }) }) @@ -991,7 +1148,7 @@ describe('subagent catalogs', () => { const manager = makeManager(mock, remote) const read = manager.refreshProjections(S1) manager.handleSessionRemoved(S1) - response.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + response.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) await read expect(manager.getListSnapshot().items.some(item => item.sessionId === S1)).toBe(false) expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(1) @@ -1081,6 +1238,20 @@ describe('remaining branches', () => { expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') }) + it.for([undefined, false, true])('forwards fork migration permission %s to the Remote request', async (allowMigration, { mock, remote }) => { + const manager = makeManager(mock, remote) + onTestFinished(() => manager.dispose()) + remote.session.fork.mockResolvedValueOnce(ok({ sessionId: S2 })) + const permission = allowMigration === undefined ? {} : { allowMigration } + + await expect(manager.fork({ sessionId: S1, atSeq: SessionSeq(7), ...permission })) + .resolves.toEqual(ok({ sessionId: S2 })) + + expect(remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: S1, atSeq: 7, ...permission }) + expect(remote.session.follow).not.toHaveBeenCalled() + expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S2, parentSessionId: S1 }) + }) + it('reconciles a fork child published before workspace attachment fails', async ({ mock, remote }) => { remote.session.fork.mockResolvedValue(err(new RemoteError('session/workspace-attach-failed', 'forked but unattached', { sessionId: S2, workspaceId: 'w1', @@ -1317,7 +1488,7 @@ describe('connected generation', () => { summary(S1), summary(S2, { parentSessionId: S1, origin: 'subagent', running: hostRunning }), ] as never[] }))) - remote.session.projections.mockImplementation(() => Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))) + remote.session.projections.mockImplementation(() => Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } }))) const manager = makeManager(mock, remote) try { await manager.refreshList() @@ -1341,13 +1512,13 @@ describe('connected generation', () => { const previous = Promise.withResolvers>>() remote.session.projections .mockReturnValueOnce(previous.promise) - .mockResolvedValue(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + .mockResolvedValue(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) const manager = makeManager(mock, remote) try { const read = manager.refreshProjections(S1) manager.handleConnected() expect(remote.session.projections.mock.calls.map(([request]) => request)).toHaveLength(2) - previous.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })) + previous.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker' }] } })) await read await vi.waitFor(() => { expect(manager.getListSnapshot().projectionsBySession[S1]).toMatchObject({ @@ -1395,7 +1566,7 @@ describe('connected generation', () => { manager.handleConnected() expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) - parent.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + parent.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: S2, mode: 'continuable', label: 'worker', }] } })) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts new file mode 100644 index 0000000000..0a14c90f0a --- /dev/null +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -0,0 +1,424 @@ +/** Projection reads distinguish ordinary cold history from history that still needs migration. */ + +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' +import type { SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionProjectionCache, { + checkpointRecord, + projectionCacheDomainSpec, + type CheckpointRecord, +} from '@deepseek-ai/dsh-session-projection-cache' +import { titleProjectionDefinition } from '@deepseek-ai/dsh-session-title' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { subagentCatalogProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/catalog.ts' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { createSessionTestController } from './test-remote.ts' + +const contexts: Context[] = [] +const roots: string[] = [] +const readAudits: Array<() => void> = [] + +afterEach(async () => { + try { + for (const audit of readAudits.splice(0)) audit() + } finally { + vi.restoreAllMocks() + try { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + } finally { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) + } + } +}) + +async function harness(withPersistence = true) { + const root = await mkdtemp(join(tmpdir(), 'dsh-projection-discovery-')) + roots.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(SessionProjectionRegistry) + ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + ctx.sessionProjections.register(titleProjectionDefinition) + if (withPersistence) { + await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' }) + const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing')) + readAudits.push(() => { + expect(list).not.toHaveBeenCalled() + }) + } + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test' }), + cwd: root, + }) + const list = vi.spyOn(ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected Session listing')) + readAudits.push(() => { + expect(list).not.toHaveBeenCalled() + }) + return { ctx, root, controller } +} + +type Harness = Awaited> + +async function stored( + fixture: Harness, + id: SessionId, + fields: Partial> = {}, + version = SESSION_FORMAT_VERSION, +) { + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: 10, + delegationDepth: 0, + isSeeded: false, + ...fields, + } + const path = join(fixture.root, 'logs', '_no-cwd', id, `session.v${version}.jsonl`) + const bytes = `${JSON.stringify({ type: 'session', ...header, version })}\n` + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes) + return { header, path, bytes } +} + +async function seedCache( + fixture: Harness, + header: SessionHeader, + catalog: 'child' | 'empty' | 'absent', + matchingIdentity = true, +) { + const rows: CheckpointRecord['rows'] = { + title: { ver: titleProjectionDefinition.stateVersion, seq: SessionSeq(50), val: 'cached title' }, + } + if (catalog !== 'absent') { + const empty = subagentCatalogProjectionDefinition.init(header, SessionLogOffset(0)) + const state = catalog === 'empty' ? empty : subagentCatalogProjectionDefinition.apply(empty, { + type: 'subagent/catalog', + seq: SessionSeq(0), + time: 1, + data: { + version: 1, + childId: SessionId('cached-child'), + childCreatedAt: 2, + mode: 'one-shot', + label: 'cached child', + }, + }) + const record = checkpointRecord.parse({ + identity: { createdAt: header.createdAt }, + rows: { subagentCatalog: { ver: subagentCatalogProjectionDefinition.stateVersion, seq: 50, val: state } }, + }) + Object.assign(rows, record.rows) + } + const record: CheckpointRecord = { + identity: { + formatVersion: SESSION_FORMAT_VERSION, + createdAt: matchingIdentity ? header.createdAt : header.createdAt + 1, + cwd: header.cwd, + isSeeded: header.isSeeded, + inheritedEventCount: SessionLogOffset(0), + }, + rows, + } + const cacheRoot = join(fixture.root, 'cache') + const path = join(cacheRoot, projectionCacheDomainSpec.name, 'sessions', `${header.id}.json`) + const bytes = JSON.stringify({ version: projectionCacheDomainSpec.version, record }) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, bytes) + await fixture.ctx.plugin(Storage) + await fixture.ctx.plugin(StorageJson, { root: cacheRoot }) + await fixture.ctx.plugin(StorageDomain, { backend: 'json' }) + await fixture.ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) + const write = vi.spyOn(fixture.ctx.sessionProjectionCache, 'write').mockRejectedValue(new Error('unexpected cache write')) + const hydrate = vi.spyOn(fixture.ctx.sessionProjectionCache, 'hydratePrepared').mockImplementation(() => { + throw new Error('unexpected cached hydration') + }) + readAudits.push(() => { + expect(write).not.toHaveBeenCalled() + expect(hydrate).not.toHaveBeenCalled() + }) + return { path, bytes } +} + +describe('Session projection discovery', () => { + it('defers V3 migration without reading child bodies or publishing a successor', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('parent'), {}, 3) + const child = await stored(fixture, SessionId('child'), { origin: 'subagent', parentSession: parent.header.id, createdAt: 20 }, 3) + const grandchild = await stored(fixture, SessionId('grandchild'), { origin: 'subagent', parentSession: child.header.id, createdAt: 30 }, 3) + const fork = await stored(fixture, SessionId('ordinary-fork'), { parentSession: parent.header.id, isSeeded: true }, 3) + const unrelated = await stored(fixture, SessionId('unrelated'), { origin: 'subagent', parentSession: SessionId('another-parent') }, 3) + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open') + const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession') + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: {}, + }) + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'migration-required', + }) + expect(fixture.ctx.sessions.list()).toEqual([]) + for (const source of [parent, child, grandchild, fork, unrelated]) { + expect(await readFile(source.path, 'utf8')).toBe(source.bytes) + expect(await readdir(dirname(source.path))).toEqual(['session.v3.jsonl']) + } + }) + + it.each(['child', 'empty'] as const)('serves a valid %s catalog cache when migration is required', async (catalog) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('cached-parent'), {}, 3) + const cache = await seedCache(fixture, parent.header, catalog) + const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions').mockRejectedValue(new Error('unexpected fallback')) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: { + title: 'cached title', + subagentCatalog: catalog === 'empty' ? [] : [{ id: 'cached-child', createdAt: 2, mode: 'one-shot', label: 'cached child' }], + }, + }) + expect(list).not.toHaveBeenCalled() + expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes) + }) + + it('serves other cached hints without inventing a missing catalog', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('title-parent'), {}, 3) + await stored(fixture, SessionId('title-child'), { origin: 'subagent', parentSession: parent.header.id }) + const cache = await seedCache(fixture, parent.header, 'absent') + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', + values: { title: 'cached title' }, + }) + expect(fixture.ctx.sessionProjectionCache.cachedSnapshot(parent.header)?.values).toEqual({ title: 'cached title' }) + expect(await readFile(cache.path, 'utf8')).toBe(cache.bytes) + }) + + it('returns no hints from an unrelated cache identity', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('recreated-parent'), {}, 3) + await seedCache(fixture, parent.header, 'child', false) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'migration-required', values: {}, + }) + }) + + it.each([true, false])('returns null for an absent Session (persistence mounted: %s)', async (withPersistence) => { + const fixture = await harness(withPersistence) + const list = vi.spyOn(fixture.ctx.sessionQuery, 'listSessions') + await expect(fixture.controller.projections({ sessionId: SessionId('missing') }, new AbortController().signal)).resolves.toBeNull() + expect(list).not.toHaveBeenCalled() + }) + + it('reads a current-format cold catalog from disk when its cache is absent', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('current-cold')) + const event = { + type: 'subagent/catalog', seq: 0, time: 1, + data: { version: 1, childId: 'current-child', childCreatedAt: 2, mode: 'continuable', label: 'worker' }, + } + await writeFile(parent.path, `${parent.bytes}${JSON.stringify(event)}\n`) + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open') + const observe = vi.spyOn(fixture.ctx.sessionQuery, 'observeSession') + const signal = new AbortController().signal + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)).resolves.toMatchObject({ + kind: 'sequenced', asOfSeq: 0, + values: { subagentCatalog: [{ id: 'current-child', mode: 'continuable', label: 'worker' }] }, + }) + expect(observe).toHaveBeenCalledWith(parent.header.id, { signal }) + expect(open).toHaveBeenCalledWith(parent.header.id, 'read', { signal }) + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(fixture.ctx.agents.get(parent.header.id)).toBeUndefined() + }) + + it('reports read-prepared historical files as migration-required until a successor is published', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('prepared-cold'), {}, 3) + const observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + observation[Symbol.dispose]() + const open = vi.spyOn(fixture.ctx.sessionPersistence, 'open').mockRejectedValue(new Error('unexpected body reread')) + + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'migration-required', + }) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + expect(open).not.toHaveBeenCalled() + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes) + expect(await readdir(dirname(parent.path))).toEqual(['session.v3.jsonl']) + }) + + it('reads sequenced projections after write publication replaces the disk format status', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('published-cold'), {}, 3) + using observation = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + const expected = observation.projections + const signal = new AbortController().signal + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + + const writer = await fixture.ctx.sessionPersistence.open(parent.header.id, 'write') + try { + await writer.flush() + } finally { + await writer.close() + } + + expect(await fixture.ctx.sessionPersistence.stat(parent.header.id)).toMatchObject({ + formatStatus: 'current', + }) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, signal)) + .resolves.toEqual({ kind: 'sequenced', ...expected }) + expect(fixture.ctx.sessions.get(parent.header.id)).toBeUndefined() + expect(await readFile(parent.path, 'utf8')).toBe(parent.bytes) + // POSIX retains the write-lock inode after close; Windows uses a semaphore. + expect((await readdir(dirname(parent.path))).filter(name => name !== 'session.lock').sort()) + .toEqual(['session.v3.jsonl', `session.v${SESSION_FORMAT_VERSION}.jsonl`].sort()) + }) + + it('reports unsupported current-format events instead of deferring migration', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('corrupt-current')) + await writeFile(parent.path, `${parent.bytes}${JSON.stringify({ type: 'missing/plugin-event', seq: 0, time: 1, data: {} })}\n`) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/internal' }) + }) + + it('reports unavailable projections from an otherwise readable observation', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('without-projections')) + using source = await fixture.ctx.sessionQuery.observeSession(parent.header.id) + const { projections: _projections, ...observation } = source + vi.spyOn(fixture.ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(observation) + + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'session/projections-unavailable' }) + }) + + it.each([false, true])('rechecks live history when the stored source disappears after preflight (attached: %s)', async (attached) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('missing-after-preflight')) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + .mockResolvedValueOnce(snapshot) + .mockImplementationOnce(() => { + if (attached) fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) + return Promise.resolve(undefined) + }) + + const result = await fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) + expect(result).toEqual(attached + ? { kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(fixture.ctx.sessions.get(parent.header.id)!) } + : null) + }) + + it('serves the live registry cut instead of a conflicting cached hint', async () => { + const fixture = await harness() + const live = fixture.ctx.sessions.create(SessionId('live'), { meta: { cwd: fixture.root } }) + live.append('turn/start', { turn: 1 }) + const expected = fixture.ctx.sessionProjections.snapshot(live) + await seedCache(fixture, live.header, 'child') + const cached = vi.spyOn(fixture.ctx.sessionProjectionCache, 'cachedSnapshot') + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(new Error('metadata unavailable')) + await expect(fixture.controller.projections({ sessionId: live.id }, new AbortController().signal)).resolves.toEqual({ + kind: 'sequenced', ...expected, + }) + expect(stat).not.toHaveBeenCalled() + expect(cached).not.toHaveBeenCalled() + }) + + it.each([true, false])('prefers a Session attached during stat (stored header present: %s)', async (present) => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('stat-race'), {}, 3) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(() => { + entered.resolve(undefined) + return finish.promise + }) + const operation = fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal) + try { + await entered.promise + const live = fixture.ctx.sessions.create(parent.header.id, { meta: parent.header }) + live.append('turn/start', { turn: 1 }) + finish.resolve(present ? snapshot : undefined) + await expect(operation).resolves.toEqual({ kind: 'sequenced', ...fixture.ctx.sessionProjections.snapshot(live) }) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + }) + + it('reports target metadata I/O failures instead of an empty result', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('failed-metadata')) + const failure = new Error('target metadata unavailable') + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValue(failure) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/internal', cause: failure }) + }) + + it('honors cancellation while waiting for target metadata', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('cancelled-metadata')) + const snapshot = await fixture.ctx.sessionPersistence.stat(parent.header.id) + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockImplementation(async () => { + entered.resolve(undefined) + await finish.promise + return snapshot + }) + const abort = new AbortController() + const operation = fixture.controller.projections({ sessionId: parent.header.id }, abort.signal) + try { + await entered.promise + abort.abort(new Error('cancelled by caller')) + finish.resolve(undefined) + await expect(operation).rejects.toMatchObject({ code: 'gateway/cancelled' }) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + }) + + it('rejects an already-cancelled read before metadata access', async () => { + const fixture = await harness() + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + await expect(fixture.controller.projections({ sessionId: SessionId('cancelled') }, AbortSignal.abort())) + .rejects.toMatchObject({ code: 'gateway/cancelled' }) + expect(stat).not.toHaveBeenCalled() + }) + + it('preserves Remote failures while reading the migration cache metadata', async () => { + const fixture = await harness() + const parent = await stored(fixture, SessionId('typed-failures'), {}, 3) + const failure = new RemoteError('gateway/internal', 'metadata failure', {}) + vi.spyOn(fixture.ctx.sessionPersistence, 'stat').mockRejectedValueOnce(failure) + await expect(fixture.controller.projections({ sessionId: parent.header.id }, new AbortController().signal)).rejects.toBe(failure) + }) + + it('rejects an empty identity without querying metadata', async () => { + const fixture = await harness() + const stat = vi.spyOn(fixture.ctx.sessionPersistence, 'stat') + await expect(fixture.controller.projections({ sessionId: SessionId('') }, new AbortController().signal)) + .rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(stat).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/session-controller/tests/remote/session.client.ts b/packages/api/session-controller/tests/remote/session.client.ts index 4d86abcf9e..591dd60985 100644 --- a/packages/api/session-controller/tests/remote/session.client.ts +++ b/packages/api/session-controller/tests/remote/session.client.ts @@ -145,7 +145,7 @@ export const sessionWorld: RemoteTable = { data: 'AA==', }), 'session/page': pageRule(ok({ records: [], hasMore: false })), - 'session/projections': ok({ asOfSeq: -1, values: { subagentCatalog: [] } }), + 'session/projections': ok({ kind: 'sequenced', asOfSeq: -1, values: { subagentCatalog: [] } }), 'subagents/prompt': ok({ messageId: 'fake-message' }), 'subagents/interruptByParent': ok({ accepted: true }), }, diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index aefd8b9288..a6b7a033dd 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -77,6 +77,53 @@ function conversationEvents(): SessionEvent[] { } describe('sessions.list cold merge', () => { + it('preserves listed format status and prefers live readiness without extra persistence reads', async () => { + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const historical = header('format-historical', 100) + const current = header('format-current', 200) + const unknown = header('format-unknown', 300) + const shared = ctx.sessions.create(sid('format-shared'), { meta: { createdAt: 400, cwd: '/proj' } }).header + const liveOnly = ctx.sessions.create(sid('format-live'), { meta: { cwd: '/proj' } }) + const snapshots: SessionPersistenceSnapshot[] = [ + { ...statSnapshot(historical), formatStatus: 'migration-required' }, + { ...statSnapshot(current), formatStatus: 'current' }, + statSnapshot(unknown), + { ...statSnapshot(shared), formatStatus: 'migration-required' }, + ] + const list = vi.fn(async () => snapshots) + const stat = vi.fn() + const open = vi.fn() + ctx.provide('sessionPersistence', { list, stat, open } as never) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + try { + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + const byId = new Map(response.value.items.map(item => [item.sessionId, item])) + expect(byId.get(historical.id)).toMatchObject({ formatStatus: 'migration-required' }) + expect(byId.get(current.id)).toMatchObject({ formatStatus: 'current' }) + expect(byId.get(unknown.id)).toMatchObject({ sessionId: unknown.id }) + expect(byId.get(unknown.id)).not.toHaveProperty('formatStatus') + expect(byId.get(shared.id)).toMatchObject({ formatStatus: 'current' }) + expect(byId.get(liveOnly.id)).toMatchObject({ formatStatus: 'current' }) + expect(list).toHaveBeenCalledTimes(1) + expect(stat).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + } finally { + observe.mockRestore() + } + } finally { + await ctx.fiber.dispose() + } + }) + it('uses a predecessor title hint with zero cold stat or body reads', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index 137eeeb190..e6ba8321ce 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -11,6 +11,7 @@ import { ToolCallId, createMessage, createUserMessage, ReasoningEffortId } from import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' import SessionStore, { TOOL_OUTCOME_UNKNOWN, SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionPersistenceRevision, type SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import TokenMeter from '@deepseek-ai/dsh-token-meter' import type { Workspace } from '@deepseek-ai/dsh-workspace' @@ -95,6 +96,151 @@ const remote = (ctx: Context) => createSessionTestRemote(ctx, { }) describe('sessions.fork', () => { + it.each([ + { allowMigration: undefined, status: 'current' }, + { allowMigration: false, status: 'current' }, + { allowMigration: false, status: undefined }, + { allowMigration: undefined, status: 'migration-required' }, + { allowMigration: true, status: 'migration-required' }, + ] as const)('forks cold history with allowMigration=$allowMigration and format=$status', async ({ allowMigration, status }) => { + const ctx = await composed() + try { + const source = ctx.sessions.prepare(sid('current-cold'), { meta: { cwd: '/proj' } }) + source.append('turn/start', { turn: 1 }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:cold-source'), + ...status === undefined ? {} : { formatStatus: status }, + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([source.header]), + stat: () => Promise.resolve(snapshot), + inspect: () => Promise.resolve({ meta: source.header, events: source.snapshotEvents() }), + }) as never) + const open = vi.spyOn(ctx.sessionPersistence, 'open') + + const response = await remote(ctx).fork({ + sessionId: source.id, + ...allowMigration === undefined ? {} : { allowMigration }, + }) + + if (!response.ok) throw response.error + expect(open).toHaveBeenCalledWith(source.id, 'read', undefined) + expect(ctx.sessions.get(source.id)).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports required migration before creating a fork when the caller disallows it', async () => { + const ctx = await composed() + try { + const sourceId = sid('requires-migration') + const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:requires-migration'), + formatStatus: 'migration-required', + } + const stat = vi.fn(async () => snapshot) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const open = vi.spyOn(ctx.sessionPersistence, 'open') + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const create = vi.spyOn(ctx.agents, 'create') + + await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } }, + }) + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(open).not.toHaveBeenCalled() + expect(observe).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + } finally { + await ctx.fiber.dispose() + } + }) + + it('forks a resident Session without consulting disk metadata or requiring a live Agent', async () => { + const ctx = await composed() + try { + const source = ctx.sessions.create(sid('resident-source'), { meta: { cwd: '/proj' } }) + source.append('turn/start', { turn: 1 }) + source.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read'))) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + + const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false }) + + if (!result.ok) throw result.error + expect(stat).not.toHaveBeenCalled() + expect(ctx.agents.get(source.id)).toBeUndefined() + expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + await ctx.fiber.dispose() + } + }) + + it('forks a source attached while its historical format stat is pending', async () => { + const ctx = await composed() + try { + const sourceId = sid('attached-during-stat') + const source = ctx.sessions.prepare(sourceId, { meta: { cwd: '/proj' } }) + const snapshot: SessionPersistenceSnapshot = { + header: source.header, + revision: SessionPersistenceRevision('test:attaching-source'), + formatStatus: 'migration-required', + } + const entered = Promise.withResolvers() + const finish = Promise.withResolvers() + const stat = vi.fn(() => { + entered.resolve(undefined) + return finish.promise + }) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false }) + try { + await entered.promise + const live = ctx.sessions.create(sourceId, { meta: source.header }) + live.append('turn/start', { turn: 1 }) + live.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + finish.resolve(snapshot) + + const result = await operation + if (!result.ok) throw result.error + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(ctx.sessions.get(result.value.sessionId)?.inheritedEventCount).toBe(2) + } finally { + finish.resolve(undefined) + await Promise.allSettled([operation]) + } + } finally { + await ctx.fiber.dispose() + } + }) + + it('reports a stat failure without treating it as required migration or opening the source', async () => { + const ctx = await composed() + try { + const sourceId = sid('failed-source-stat') + const stat = vi.fn(() => Promise.reject(new Error('source metadata unavailable'))) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const create = vi.spyOn(ctx.agents, 'create') + + await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' }, + }) + + expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) + expect(observe).not.toHaveBeenCalled() + expect(create).not.toHaveBeenCalled() + } finally { + await ctx.fiber.dispose() + } + }) + it('keeps the completed manual compaction checkpoint when atSeq is omitted', async () => { const ctx = new Context() try { diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index e25339bc93..d92a83198e 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -79,6 +79,31 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise { } describe('list store projection', () => { + it('publishes format-status changes and preserves a newer update over an in-flight list', async ({ bench }) => { + const b = bench() + const historical = { + sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, agentAvailable: false, + formatStatus: 'migration-required' as const, + } + b.mock.remote.session.list.mockResolvedValue(ok({ items: [historical] })) + await b.svc.refresh() + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('migration-required') + + const pending = Promise.withResolvers>>() + b.unblock.push(() => { pending.resolve(ok({ items: [historical] })) }) + b.mock.remote.session.list.mockReturnValueOnce(pending.promise) + const refreshing = b.svc.refresh() + b.svc.handleSessionAdded({ ...historical, formatStatus: 'current' }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current') + pending.resolve(ok({ items: [historical] })) + await refreshing + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.formatStatus).toBe('current') + expect(b.mock.remote.session.projections).not.toHaveBeenCalled() + }) + it('projects durable titles separately from cwd/id display fallbacks and parent links', async ({ bench }) => { const b = bench() b.svc.handleControlFrame({ @@ -730,7 +755,7 @@ describe('catalog-addressed navigation', () => { const b = bench() await feedList(b, [{ id: 'root' }]) b.mock.remote.session.projections.mockResolvedValue(ok({ - asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] }, + kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('child'), createdAt: 1, mode: 'continuable', label: 'Child' }] }, })) await b.svc.refreshProjections(sid('root')) const address = b.svc.subagentAddress(sid('child'))! @@ -761,7 +786,7 @@ describe('catalog-addressed navigation', () => { type: 'projection', sessionId: sid('one-shot'), key: 'title', value: title, seq: 2, }) b.mock.remote.session.projections.mockResolvedValue(ok({ - asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] }, + kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: sid('one-shot'), createdAt: 1, mode: 'one-shot' }] }, })) await b.svc.refreshProjections(sid('root')) expect(b.svc.list.getSnapshot().byId[sid('one-shot')]?.displayTitle).toBe(title ?? 'one-shot') @@ -780,16 +805,16 @@ describe('catalog-addressed navigation', () => { b.mock.remote.session.projections.mockImplementation((payload) => { const { sessionId } = payload as { sessionId: SessionId } if (sessionId === sid('root')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('child'), mode: 'continuable', label: 'Child', }] } })) } if (sessionId === sid('child')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { title: 'Child session title', subagentCatalog: [{ createdAt: 1, id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', }] } })) } - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) }) await feedList(b, [ { id: 'root' }, @@ -819,16 +844,16 @@ describe('catalog-addressed navigation', () => { b.mock.remote.session.projections.mockImplementation((payload) => { const { sessionId } = payload as { sessionId: SessionId } if (sessionId === sid('root')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('child'), mode: 'continuable', label: 'Child', }] } })) } if (sessionId === sid('child')) { - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ createdAt: 1, id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', }] } })) } - return Promise.resolve(ok({ asOfSeq: 0, values: { subagentCatalog: [] } })) + return Promise.resolve(ok({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [] } })) }) await feedList(b, [{ id: 'root' }]) await b.svc.refreshProjections(sid('root')) @@ -907,6 +932,38 @@ describe('create', () => { }) describe('fork', () => { + it.for([undefined, false, true])('forwards migration permission %s without opening the source', async (allowMigration, { bench }) => { + const b = bench() + b.mock.remote.session.fork.mockResolvedValueOnce(ok({ sessionId: sid('child') })) + const source = sid('source') + const permission = allowMigration === undefined ? {} : { allowMigration } + + await expect(b.svc.fork({ sessionId: source, atSeq: 7, ...permission })).resolves.toBe(sid('child')) + + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, atSeq: 7, ...permission }) + expect(b.svc.binding(source)).toBeUndefined() + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) + }) + + it('preserves the Host migration refusal without creating or opening a Session', async ({ bench }) => { + const b = bench() + const source = sid('source') + const error = new RemoteError('session/migration-required', 'migration required', { sessionId: source }) + const onCreated = vi.fn<(childId: SessionId) => void>() + b.mock.remote.session.fork.mockResolvedValueOnce(err(error)) + + const failure = await b.svc.fork({ sessionId: source, allowMigration: false, onCreated }) + .catch((cause: unknown) => cause) + + expect(failure).toBeInstanceOf(SessionForkError) + expect(failure).toMatchObject({ sourceSessionId: source, rpcError: error }) + expect(b.mock.remote.session.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: source, allowMigration: false }) + expect(onCreated).not.toHaveBeenCalled() + expect(b.mock.remote.session.rename).not.toHaveBeenCalled() + expect(b.svc.list.getSnapshot().ids).toEqual([]) + expect(b.mock.log.requests(FOLLOW)).toHaveLength(0) + }) + it('propagates a failed fork without creating or retaining a child', async ({ bench }) => { const b = bench() const error = new RemoteError('session/not-found', 'source missing', { sessionId: sid('source') }) diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index 4d9c4360fc..de1852991a 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -2,12 +2,10 @@ import { Context } from '@deepseek-ai/cordis' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore, { SESSION_FORMAT_VERSION, SessionLogOffset, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SESSION_FORMAT_VERSION, SessionSeq, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' -import { SessionQueryError } from '@deepseek-ai/dsh-session-query' -import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import SubagentRuntime from '@deepseek-ai/dsh-subagent' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { createSessionTestController, installSessionReadTestServices, @@ -41,8 +39,15 @@ const events: SessionEvent[] = [{ }, }] +const contexts: Context[] = [] +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) +}) + async function bench() { const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { @@ -82,20 +87,48 @@ describe('SessionController subagent catalog', () => { } }) - it('requests a stored parent catalog by id without loading an Agent', async () => { + it.each([false, true])('reads an uncached parent catalog without loading an Agent (explicit current format: %s)', async (withFormat) => { const { ctx, controller } = await bench() + const stored = await ctx.sessionPersistence.stat(PARENT) + if (stored === undefined) throw new Error('parent fixture is missing') + const stat = vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce(withFormat + ? { ...stored, formatStatus: 'current' } + : stored) const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const open = vi.spyOn(ctx.sessionPersistence, 'open') + const resume = vi.spyOn(ctx.agents, 'resume').mockRejectedValue(new Error('unexpected Agent resume')) const signal = new AbortController().signal - await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ values: { subagentCatalog: [{ - id: CHILD, - mode: 'continuable', - label: 'worker', - }] } }) - expect(observe).toHaveBeenCalledOnce() - expect(observe).toHaveBeenCalledWith(PARENT, { - signal, + await expect(controller.projections({ sessionId: PARENT }, signal)).resolves.toMatchObject({ + kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'continuable', label: 'worker' }] }, }) + expect(stat).toHaveBeenCalledWith(PARENT, { signal }) + expect(observe).toHaveBeenCalledWith(PARENT, { signal }) + expect(open).toHaveBeenCalledWith(PARENT, 'read', { signal }) + expect(resume).not.toHaveBeenCalled() + expect(ctx.sessions.get(PARENT)).toBeUndefined() + expect(ctx.agents.get(PARENT)).toBeUndefined() + }) + + it('uses disk migration status instead of the normalized header version to defer a cold catalog read', async () => { + const { ctx, controller } = await bench() + const stored = await ctx.sessionPersistence.stat(PARENT) + if (stored === undefined) throw new Error('parent fixture is missing') + vi.spyOn(ctx.sessionPersistence, 'stat').mockResolvedValueOnce({ + ...stored, + formatStatus: 'migration-required', + }) + const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') + const open = vi.spyOn(ctx.sessionPersistence, 'open') + + expect(stored.header.version).toBe(SESSION_FORMAT_VERSION) + await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)) + .resolves.toEqual({ kind: 'migration-required', values: {} }) + + expect(observe).not.toHaveBeenCalled() + expect(open).not.toHaveBeenCalled() + expect(ctx.sessions.get(PARENT)).toBeUndefined() + expect(ctx.agents.get(PARENT)).toBeUndefined() }) it('serves a live parent from its maintained registry state without folding its log', async () => { @@ -113,7 +146,7 @@ describe('SessionController subagent catalog', () => { const hydrate = vi.spyOn(ctx.sessionProjections, 'hydrate') const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal) - expect(result).toMatchObject({ values: { subagentCatalog: [{ + expect(result).toMatchObject({ kind: 'sequenced', values: { subagentCatalog: [{ id: CHILD, mode: 'one-shot', label: 'live worker', @@ -140,7 +173,7 @@ describe('SessionController subagent catalog', () => { const unavailable = await controller.list({}, new AbortController().signal) expect(unavailable.items.find(item => item.sessionId === PARENT)?.agentAvailable).toBe(false) const result = await controller.projections({ sessionId: PARENT }, new AbortController().signal) - expect(result).toMatchObject({ asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } }) + expect(result).toMatchObject({ kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: CHILD, createdAt: 8, mode: 'one-shot' }] } }) }) it('does not republish a Session when its Agent outlives the Session registration', async () => { @@ -158,6 +191,7 @@ describe('SessionController subagent catalog', () => { it('reads registered projections without a subagent provider or catalog key', async () => { const ctx = new Context() + contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) @@ -165,7 +199,8 @@ describe('SessionController subagent catalog', () => { const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) const expected = ctx.sessionProjections.snapshot(session) expect(expected.values.subagentCatalog).toBeUndefined() - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).resolves.toEqual(expected) + await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)) + .resolves.toEqual({ kind: 'sequenced', ...expected }) expect(ctx.agents.get(PARENT)).toBeUndefined() }) @@ -183,36 +218,19 @@ describe('SessionController subagent catalog', () => { }) }) - it('maps query cancellation, missing projections, and unexpected failures', async () => { + it('reports a live snapshot failure without falling back to stored metadata', async () => { const { ctx, controller } = await bench() - const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') - observe.mockRejectedValueOnce(new SessionQueryError( - 'query cancelled', - 'SESSION_QUERY_ABORTED', - )) - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/cancelled', + const parent = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) + const failure = new Error('projection snapshot failed') + const snapshot = vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementationOnce(() => { + throw failure }) - - observe.mockRestore() - // An observation without the catalog state means no registry served it. - const stateless: SessionObservation = { - source: 'prepared', - header, - inheritedEventCount: SessionLogOffset(0), - events, - cursor: SessionSeq(0), - retain: () => stateless, - [Symbol.dispose]: () => {}, - } - vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValueOnce(stateless) - await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ - code: 'session/projections-unavailable', - }) - - vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValueOnce(new Error('storage offline')) + const stat = vi.spyOn(ctx.sessionPersistence, 'stat') await expect(controller.projections({ sessionId: PARENT }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', + cause: failure, }) + expect(snapshot).toHaveBeenCalledWith(parent) + expect(stat).not.toHaveBeenCalled() }) }) diff --git a/packages/client/ui-session/tests/status-controller.client.spec.ts b/packages/client/ui-session/tests/status-controller.client.spec.ts index 1bf3bfec88..e2e75adede 100644 --- a/packages/client/ui-session/tests/status-controller.client.spec.ts +++ b/packages/client/ui-session/tests/status-controller.client.spec.ts @@ -14,6 +14,7 @@ it('preserves unlisted child status through metadata updates and main view ackno sessionId: parentId, updatedAt: 1, running: false, blank: false, agentAvailable: true, }] })) mock.remote.session.projections.mockResolvedValue(ok({ + kind: 'sequenced', asOfSeq: 0, values: { subagentCatalog: [{ id: childId, createdAt: 1, mode: 'continuable', label: 'Child' }] }, })) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 3b10eb1e5e..5bf8a6dee4 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -30,8 +30,8 @@ en: c158f12dcaed87b7 zh: e5d315e7b1fb4fa9 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/catalog-derivation: - en: db5646fa9b348fe8 - zh: a2e9c0c03bc0c68f + en: 5531dcc1fb976cff + zh: 6fe779f4f1c34266 /deepseek-ai-dsh-client-ui-subagent/understand-the-implementation/duration-completion-and-tokens: en: b817e9907392f59c zh: e42feb97d572a989 diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index 2969d1841e..30cea03817 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -55,11 +55,11 @@ The [subagent runtime](../../subagent/subagent/README.md) owns continuation and ### Catalog derivation -The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Local rows remain expandable while their catalog is absent, loading, or failed, and become known leaves after a ready empty catalog. External rows are always leaves and do not load child Sessions. +The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row reads its target's projections, including ordinary cold history. A list row with `formatStatus: migration-required` displays “Migration required” before expansion and keeps its history-opening actions without descendant expansion or changing its subagent mode; a projection read requiring migration without a catalog supplies the same notice. Opening the Session supplies its catalog through history and control projections. Otherwise, an available catalog remains usable, including an explicit empty catalog. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. Other rows remain expandable while their catalogs are absent, loading, or failed, and become known leaves after ready empty catalogs. External rows are always leaves and do not load child Sessions. Opening a catalog dropdown does not request its root catalog. Child-catalog expansion and failed-read retries call `refreshProjection`; shared projection-value changes update the display automatically. -Breadcrumb addresses derive from the Provider-bound Session address and loaded parent catalogs, including never-selected ancestors. +Breadcrumb addresses derive from the Provider-bound Session address, loaded parent catalogs, or already-listed subagent lineage, including never-selected ancestors. ### Duration, completion, and tokens diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index d74259d240..0479608885 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -55,11 +55,11 @@ kind: "package-reference" ### 目录派生 -页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。本地行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 +页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行读取目标投影,包括普通冷历史。列表行的 `formatStatus` 为 `migration-required` 时,行在展开前即显示“需要迁移”,保留打开历史的操作,但不能展开下级,也不改变子会话的 mode;投影读取需要迁移且缺少目录时,也显示同一提示。打开会话后,历史与 control 投影提供其目录。其他情况下,已有目录仍可使用,包括显式空目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。其他行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。外部行始终为叶子,不加载子 Session。 打开目录下拉菜单不会请求其根目录。展开子节点目录或重试失败读取时调用 `refreshProjection`;共享投影值的变化会自动更新显示。 -面包屑地址从 Provider 所绑定的 Session 地址和已加载的 parent 目录推导,也包括从未选中过的祖先。 +面包屑地址从 Provider 所绑定的 Session 地址、已加载的 parent 目录或已列出的 subagent 谱系推导,也包括从未选中过的祖先。 ### 耗时、完成状态与 token diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 23e1c42cb4..611557c923 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-token-meter/client' import css from './SubagentHeaderLineage.module.css' type SubagentCatalogSnapshot = Omit & { - state: 'loading' | 'ready' | 'error' + state: 'loading' | 'ready' | 'migration-required' | 'error' entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[] } type Catalogs = Readonly> @@ -210,7 +210,10 @@ function CatalogRows({ return () => { clearInterval(timer) } }, [running]) const emptyLoading = catalog.state === 'loading' && catalog.entries.length === 0 - const reserveDisclosure = catalog.entries.some(entry => entry.mode !== 'external' && !isKnownLeaf(catalogs[entry.id])) + const reserveDisclosure = catalog.entries.some(entry => + entry.mode !== 'external' && !isKnownLeaf(catalogs[entry.id]) + && summaries[entry.id]?.formatStatus !== 'migration-required' + && catalogs[entry.id]?.state !== 'migration-required') return ( <> {emptyLoading && ( @@ -233,8 +236,11 @@ function CatalogRows({ const external = entry.mode === 'external' const childCatalog = catalogs[entry.id] const isCurrent = entry.id === currentSessionId - const isExpanded = expanded.has(entry.id) const knownLeaf = external || isKnownLeaf(childCatalog) + const requiresOpening = summaries[entry.id]?.formatStatus === 'migration-required' + || childCatalog?.state === 'migration-required' + const canExpand = !knownLeaf && !requiresOpening + const isExpanded = canExpand && expanded.has(entry.id) const childLoading = childCatalog === undefined || (childCatalog.state === 'loading' && childCatalog.entries.length === 0) const summary = summaries[entry.id] @@ -248,7 +254,7 @@ function CatalogRows({ : completed ? t('activity.completed') : t('activity.inactive') - const secondary = [summary?.title, mode, activity] + const secondary = [summary?.title, requiresOpening ? t('catalog.migrationRequired') : mode, activity] .filter(value => value !== undefined) .join(' · ') const totalTokens = tokenTotal(summary?.projectionValues?.tokenUsage) @@ -285,7 +291,7 @@ function CatalogRows({ event.stopPropagation() open() } else if ( - (event.key === 'ArrowRight' && !knownLeaf && !isExpanded) + (event.key === 'ArrowRight' && canExpand && !isExpanded) || (event.key === 'ArrowLeft' && isExpanded) ) { event.preventDefault() @@ -308,12 +314,12 @@ function CatalogRows({ aria-level={level} aria-current={isCurrent || undefined} aria-label={[label, secondary, metrics].filter(value => value !== '').join(' ')} - {...knownLeaf ? {} : { 'aria-expanded': isExpanded }} + {...canExpand ? { 'aria-expanded': isExpanded } : {}} className={css.row} onClick={open} onKeyDown={handleKey} > - {knownLeaf + {!canExpand ? reserveDisclosure && : (