Merge pull request #5508 from deepseek-harness/worktree-waitmigration

feat(session): guard migration in sidebar fork and subagent catalogs
This commit is contained in:
imccyu authored and GitHub committed 2026-10-08 19:47:55 +08:00
commit b492219bd2
87 files changed
+2300 -286

No files matched your search

@@ -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
@@ -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 |
@@ -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 |
@@ -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
@@ -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.
@@ -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 重定向提案讨论,本决策不实现该流程。
@@ -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
@@ -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.
@@ -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 主线程的同步解析和验证暂停;相关调度调整必须依据实测。只读准备与写入发布的完成定义仍是实施前置选择。
+1
View File
@@ -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",
@@ -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"
+84 -7
View File
@@ -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<string, unknown> => JSON.parse(line) as Record<string, unknown>)
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'])
})
})
@@ -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<HTMLElement>('[data-composer-text-ref]')!
+171 -8
View File
@@ -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<typeof watchConsole>
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<string, unknown> => JSON.parse(line) as Record<string, unknown>)
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()
}
})
})
+1 -1
View File
@@ -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. */
+1 -1
View File
@@ -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. */
+5 -5
View File
@@ -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`) | - |
+5 -5
View File
@@ -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`) | - |
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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 |
|---|---|---|
+2 -2
View File
@@ -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)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -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"
]
},
{
+2
View File
@@ -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. */
+2
View File
@@ -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. */
+2
View File
@@ -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'
}
```
+2
View File
@@ -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'
}
```
+5 -5
View File
@@ -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<SessionForkValue>
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* 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<SessionFollowFrame>
/**
* 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<SessionProjectionsValue>
+5 -5
View File
@@ -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<SessionForkValue>
@Remote('fork') async fork(request: SessionForkRequest): Promise<SessionForkValue>
/**
* 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<SessionFollowFrame>
/**
* 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<SessionProjectionsValue>
@@ -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
+3 -1
View File
@@ -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.
+3 -1
View File
@@ -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 对进行中请求的变更重放会纠正其可用性,带序号的投影会覆盖缓存提示。
@@ -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:*",
@@ -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<SessionId>
/**
* Borrow an already-retained Agent-scoped Context without extending its lifetime.
@@ -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<Partial<SessionProjectionMap>>
@@ -61,7 +61,7 @@ export interface SessionListSnapshot {
/** Shared projection values and the lifecycle of their explicit baseline read. */
export interface SessionProjectionSnapshot {
readonly values: Readonly<Partial<SessionProjectionMap>>
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<RemoteResult<{ sessionId: SessionId }>> {
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)
}
@@ -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
@@ -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<void> {
@@ -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<SessionId> {
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 }),
+40 -9
View File
@@ -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<SessionForkValue> {
async fork(request: SessionForkRequest): Promise<SessionForkValue> {
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<SessionProjectionsValue> {
@@ -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<SessionProjectionsValue, { readonly kind: 'sequenced' }> | 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.
+8 -6
View File
@@ -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,
+10 -2
View File
@@ -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 {
@@ -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<Awaited<ReturnType<typeof remote.session.projections>>>()
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<Awaited<ReturnType<typeof remote.session.projections>>>()
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<Awaited<ReturnType<typeof remote.session.projections>>>()
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<Awaited<ReturnType<typeof remote.session.projections>>>()
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',
}] } }))
@@ -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<ReturnType<typeof harness>>
async function stored(
fixture: Harness,
id: SessionId,
fields: Partial<Pick<SessionHeader, 'createdAt' | 'parentSession' | 'origin' | 'isSeeded'>> = {},
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<undefined>()
const finish = Promise.withResolvers<SessionPersistenceSnapshot | undefined>()
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<undefined>()
const finish = Promise.withResolvers<undefined>()
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()
})
})
@@ -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 }),
},
@@ -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)
@@ -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<undefined>()
const finish = Promise.withResolvers<SessionPersistenceSnapshot | undefined>()
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 {
@@ -79,6 +79,31 @@ async function feedList(b: Bench, rows: FeedRow[]): Promise<void> {
}
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<Awaited<ReturnType<typeof b.mock.remote.session.list>>>()
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') })
@@ -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()
})
})
@@ -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' }] },
}))
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
@@ -16,7 +16,7 @@ import type {} from '@deepseek-ai/dsh-token-meter/client'
import css from './SubagentHeaderLineage.module.css'
type SubagentCatalogSnapshot = Omit<SessionProjectionSnapshot, 'values' | 'state'> & {
state: 'loading' | 'ready' | 'error'
state: 'loading' | 'ready' | 'migration-required' | 'error'
entries: (SessionProjectionMap['subagentCatalog'][number] & { activity: 'running' | 'inactive' })[]
}
type Catalogs = Readonly<Record<SessionId, SubagentCatalogSnapshot>>
@@ -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 && <span className={css.disclosureSpace} />
: (
<button
@@ -367,7 +373,7 @@ function CatalogRows({
)}
</div>
</div>
{isExpanded && !knownLeaf && (
{isExpanded && (
<div
role="group"
className={css.children}
@@ -453,15 +459,23 @@ function CatalogDropdown({
const projections = useSessions(state => state.projectionsBySession)
const summaries = useSessions(state => state.byId)
const statuses = useSessionStatus(value => value)
const catalogs = useMemo<Catalogs>(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
state: snapshot.state === 'idle'
? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error,
entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
})),
}])), [projections, summaries, statuses])
const catalogs = useMemo<Catalogs>(() => {
return Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => {
const projected = snapshot.values.subagentCatalog
const state = snapshot.state === 'idle'
? projected === undefined ? 'loading' : 'ready'
: snapshot.state === 'migration-required' && projected !== undefined ? 'ready' : snapshot.state
return [id, {
state,
error: snapshot.error,
entries: (projected ?? []).map(entry => ({
...entry,
activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true
? 'running' as const : 'inactive' as const,
})),
} satisfies SubagentCatalogSnapshot]
}))
}, [projections, summaries, statuses])
const catalog = catalogs[rootSessionId]
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState<CSSProperties>()
@@ -475,7 +489,7 @@ function CatalogDropdown({
const pinnedRef = useRef(false)
const currentEntry = currentSessionId === undefined
? undefined
: catalog?.entries.find(entry => entry.id === currentSessionId)
: projections[rootSessionId]?.values.subagentCatalog?.find(entry => entry.id === currentSessionId)
const switcherDisplayTitle = currentEntry !== undefined
? currentEntry.label ?? currentEntry.id
: displayTitle
@@ -778,7 +792,8 @@ export function SubagentHeaderLineage({
for (const [parentId, snapshot] of Object.entries(state.projectionsBySession)) {
if (snapshot.values.subagentCatalog?.some(entry => entry.id === lineageSessionId)) return parentId as SessionId
}
return undefined
const summary = state.byId[lineageSessionId]
return summary?.origin === 'subagent' ? summary.parentId : undefined
})
const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t }
// Root sessions carry no breadcrumb; their descendant count lives in the
@@ -787,7 +802,7 @@ export function SubagentHeaderLineage({
return (
<>
<CatalogDropdown
key={lineageSessionId}
key={`switcher:${lineageSessionId}`}
rootSessionId={parentId}
currentSessionId={lineageSessionId}
variant="switcher"
@@ -797,7 +812,7 @@ export function SubagentHeaderLineage({
/>
{openTitle === undefined && (
<CatalogDropdown
key={lineageSessionId}
key={`children:${lineageSessionId}`}
rootSessionId={lineageSessionId}
variant="count"
{...shared}
@@ -21,6 +21,7 @@ export const zh = {
'tokens.total': '{value} tok',
'loading.label': '正在加载子智能体…',
'load.error': '无法加载子智能体',
'catalog.migrationRequired': '需要迁移',
'retry': '重试',
'mode.external': '外部任务',
'mode.oneShot': '一次性',
@@ -65,6 +66,7 @@ export const en: Record<SubagentKey, string> = {
'tokens.total': '{value} tok',
'loading.label': 'Loading subagents…',
'load.error': 'Unable to load subagents',
'catalog.migrationRequired': 'Migration required',
'retry': 'Retry',
'mode.external': 'External task',
'mode.oneShot': 'one-shot',
@@ -13,7 +13,7 @@ import {
type SubagentCatalogActionProps, type SubagentHeaderLineageProps,
} from '../src/client/SubagentHeaderLineage.tsx'
import { SubagentReadOnlyComposer } from '../src/client/SubagentReadOnlyComposer.tsx'
import { zh } from '../src/client/locales.ts'
import { en, zh } from '../src/client/locales.ts'
afterEach(() => {
cleanup()
@@ -30,7 +30,7 @@ type CatalogFixtureEntry<Entry = SubagentCatalogEntry> = Entry extends SubagentC
? Omit<Entry, 'createdAt'> & { activity: 'running' | 'inactive' }
: never
type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; parentAvailable: boolean; state: 'loading' | 'ready' | 'error'; error: SessionListState['projectionsBySession'][SessionId]['error'] }
type CatalogFixture = { entries: readonly CatalogFixtureEntry[]; parentAvailable: boolean; state: SessionListState['projectionsBySession'][SessionId]['state']; error: SessionListState['projectionsBySession'][SessionId]['error'] }
function catalog(over: Partial<CatalogFixture> = {}): CatalogFixture {
return {
@@ -686,7 +686,7 @@ describe('SubagentHeaderLineage', () => {
})
})
it('shows one generic state while a child catalog loads', () => {
it('keeps ordinary cold descendants loading until their real catalog arrives', () => {
const secondGrandchild = 'grandchild-2' as SessionId
const summaries = {
[CHILD]: { ...summary(CHILD, 1), running: true },
@@ -699,7 +699,10 @@ describe('SubagentHeaderLineage', () => {
},
}
const deferred = props(catalog(), {}, summaries)
const view = render(<HeaderCatalog {...deferred} />)
const initial = deferred.useSessions(state => state)
let state: SessionListState = { ...initial, ids: [CHILD, GRANDCHILD, secondGrandchild] }
const current = { ...deferred, useSessions: <T,>(select: (snapshot: SessionListState) => T): T => select(state) }
const view = render(<HeaderCatalog {...current} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' }))
@@ -707,12 +710,20 @@ describe('SubagentHeaderLineage', () => {
expect(screen.getByRole('group').getAttribute('aria-busy')).toBe('true')
expect(screen.getByText('正在加载子智能体…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: '正在加载子智能体' })).toBeNull()
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
expect(screen.queryByText(/模式未知/)).toBeNull()
const loading = props(catalog(), {
[CHILD]: catalog({ entries: [], state: 'loading' }),
}, summaries)
view.rerender(<HeaderCatalog {...loading} />)
state = {
...state,
projectionsBySession: {
...state.projectionsBySession,
[CHILD]: { state: 'loading', error: null, values: {} },
},
}
view.rerender(<HeaderCatalog {...current} />)
expect(screen.getByText('正在加载子智能体…')).toBeTruthy()
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
expect(screen.queryByText(/模式未知/)).toBeNull()
const ready = props(catalog(), {
[CHILD]: catalog({
@@ -728,11 +739,179 @@ describe('SubagentHeaderLineage', () => {
],
}),
}, summaries)
view.rerender(<HeaderCatalog {...ready} />)
state = { ...state, projectionsBySession: ready.useSessions(value => value.projectionsBySession) }
view.rerender(<HeaderCatalog {...current} />)
expect(screen.getByRole('group').getAttribute('aria-busy')).toBeNull()
expect(screen.getByRole('treeitem', { name: /indexer/ }).querySelector('[data-state="idle"]')).not.toBeNull()
expect(screen.getByRole('treeitem', { name: /critic/ }).querySelector('[data-state="ongoing"]')).not.toBeNull()
expect(screen.queryByText('正在加载子智能体…')).toBeNull()
expect(screen.queryByText(/模式未知/)).toBeNull()
expect(deferred.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD)
expect(deferred.openChild).not.toHaveBeenCalled()
})
it.each([false, true])('shows list migration status before expanding, with cached descendants=%s', (cached) => {
const input = props(catalog(), cached ? {
[CHILD]: catalog({ entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'inactive' }] }),
} : {}, {
[CHILD]: { ...summary(CHILD, 1), formatStatus: 'migration-required', running: true },
})
let state = input.useSessions(value => value)
const current = { ...input, useSessions: <T,>(select: (value: SessionListState) => T): T => select(state) }
const view = render(<HeaderCatalog {...current} />)
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
hoverCatalog(trigger)
const child = screen.getByRole('treeitem', { name: /worker/ })
expect(within(child).getByText(/需要迁移/)).toBeTruthy()
expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull()
fireEvent.keyDown(child, { key: 'ArrowRight' })
expect(input.refreshProjection).not.toHaveBeenCalled()
fireEvent.click(child)
expect(input.openChild).toHaveBeenCalledExactlyOnceWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
state = { ...state, byId: { ...state.byId, [CHILD]: { ...state.byId[CHILD]!, formatStatus: 'current' } } }
view.rerender(<HeaderCatalog {...current} />)
hoverCatalog(trigger)
expect(screen.queryByText(/需要迁移/)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' }))
expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD)
})
it.each(['ready', 'migration-required'] as const)('stops at a migration-required child until its real catalog arrives with %s read state', (readState) => {
const fork = 'ordinary-fork' as SessionId
const summaries = {
[CHILD]: { ...summary(CHILD, 1), running: true, parentId: PARENT, origin: 'subagent' as const },
[GRANDCHILD]: { ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' as const },
[fork]: { ...summary(fork, 1), parentId: CHILD },
}
const input = props(catalog(), {}, summaries)
const initial = input.useSessions(state => state)
let state: SessionListState = {
...initial,
ids: [CHILD, GRANDCHILD, fork],
projectionsBySession: {
...initial.projectionsBySession,
[CHILD]: { state: 'loading', error: null, values: {} },
},
}
const current = { ...input, useSessions: <T,>(select: (snapshot: SessionListState) => T): T => select(state) }
const view = render(<HeaderCatalog {...current} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' }))
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
state = {
...state,
projectionsBySession: {
...state.projectionsBySession,
[CHILD]: { state: 'migration-required', error: null, values: {} },
},
}
view.rerender(<HeaderCatalog {...current} />)
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
expect(screen.queryByRole('treeitem', { name: /ordinary-fork/ })).toBeNull()
const child = screen.getByRole('treeitem', { name: /worker/ })
expect(within(child).getByText(/需要迁移/)).toBeTruthy()
expect(within(child).queryByText(/模式未知/)).toBeNull()
expect(en['catalog.migrationRequired']).toBe('Migration required')
expect(child.hasAttribute('aria-expanded')).toBe(false)
expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull()
expect(screen.queryByRole('group')).toBeNull()
expect(screen.queryByText('正在加载子智能体…')).toBeNull()
fireEvent.keyDown(child, { key: 'ArrowRight' })
expect(input.refreshProjection).toHaveBeenCalledExactlyOnceWith(CHILD)
expect(input.openChild).not.toHaveBeenCalled()
fireEvent.click(child)
expect(input.openChild).toHaveBeenCalledExactlyOnceWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
expect(input.openChildAside).not.toHaveBeenCalled()
const projected = props(catalog(), {
[CHILD]: catalog({
state: readState,
entries: [{ id: GRANDCHILD, mode: 'continuable', label: 'indexer', activity: 'inactive' }],
}),
}, summaries)
state = { ...state, projectionsBySession: projected.useSessions(value => value.projectionsBySession) }
view.rerender(<HeaderCatalog {...current} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ }))
fireEvent.click(screen.getByRole('button', { name: '展开 worker 的下级子智能体' }))
expect(screen.queryByText(/需要迁移/)).toBeNull()
expect(screen.queryByText(/模式未知/)).toBeNull()
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
fireEvent.click(screen.getByRole('treeitem', { name: /indexer/ }))
expect(input.openChild).toHaveBeenLastCalledWith({
parentSessionId: CHILD, childSessionId: GRANDCHILD, mode: 'continuable',
})
expect(input.openChild).toHaveBeenCalledTimes(2)
})
it.each(['pending', 'ready'] as const)('opens a migration-required child through keyboard and aside while the Session list is %s', (phase) => {
const input = props(catalog(), {}, {
[CHILD]: { ...summary(CHILD, 1), running: true, parentId: PARENT, origin: 'subagent' },
[GRANDCHILD]: { ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' },
})
const initial = input.useSessions(state => state)
const state: SessionListState = {
...initial,
ids: [CHILD, GRANDCHILD],
phase,
projectionsBySession: {
...initial.projectionsBySession,
[CHILD]: { state: 'migration-required', error: null, values: {} },
},
}
const current = { ...input, useSessions: <T,>(select: (snapshot: SessionListState) => T): T => select(state) }
render(<HeaderCatalog {...current} />)
const trigger = screen.getByRole('button', { name: /1 个子智能体,正在运行/ })
hoverCatalog(trigger)
const child = screen.getByRole('treeitem', { name: /worker/ })
fireEvent.keyDown(child, { key: 'ArrowRight' })
fireEvent.keyDown(child, { key: 'ArrowRight' })
expect(input.refreshProjection).not.toHaveBeenCalled()
expect(within(child).getByText(/需要迁移/)).toBeTruthy()
expect(within(child).queryByText(/模式未知/)).toBeNull()
expect(child.hasAttribute('aria-expanded')).toBe(false)
expect(within(child).queryByRole('button', { name: /下级子智能体/ })).toBeNull()
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
expect(screen.queryByText('正在加载子智能体…')).toBeNull()
expect(screen.queryByRole('group')).toBeNull()
expect(input.openChild).not.toHaveBeenCalled()
fireEvent.keyDown(child, { key: 'Enter' })
expect(input.openChild).toHaveBeenCalledExactlyOnceWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
expect(input.openChildAside).not.toHaveBeenCalled()
hoverCatalog(trigger)
const aside = within(screen.getByRole('treeitem', { name: /worker/ }))
.getByRole('button', { name: '在侧边栏打开 worker' })
fireEvent.keyDown(aside, { key: 'Enter' })
fireEvent.click(aside)
expect(input.openChildAside).toHaveBeenCalledExactlyOnceWith({
parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
})
expect(input.openChild).toHaveBeenCalledOnce()
expect(input.refreshProjection).not.toHaveBeenCalled()
})
it.each(['ready', 'migration-required'] as const)('keeps an explicitly empty %s catalog as a leaf despite listed descendants', (state) => {
const input = props(catalog(), { [CHILD]: catalog({ entries: [], state }) }, {
[CHILD]: { ...summary(CHILD, 1), running: true },
[GRANDCHILD]: { ...summary(GRANDCHILD, 1), parentId: CHILD, origin: 'subagent' },
})
const initial = input.useSessions(state => state)
render(<HeaderCatalog {...input} useSessions={select => select({ ...initial, ids: [CHILD, GRANDCHILD] })} />)
hoverCatalog(screen.getByRole('button', { name: /1 个子智能体,正在运行/ }))
expect(screen.queryByRole('button', { name: '展开 worker 的下级子智能体' })).toBeNull()
expect(screen.queryByRole('treeitem', { name: /grandchild/ })).toBeNull()
})
it('uses ArrowRight and ArrowLeft for branch disclosure', async () => {
@@ -795,7 +974,7 @@ describe('SubagentHeaderLineage', () => {
expect(failed.refreshProjection).toHaveBeenCalledWith(PARENT)
})
it('does not expose summary-only descendants as catalog rows', () => {
it.each(['absent', 'idle', 'loading', 'ready'] as const)('does not expose header-only descendants when the projection read is %s', (state) => {
const second = 'child-2' as SessionId
const summaries = {
[CHILD]: {
@@ -806,12 +985,13 @@ describe('SubagentHeaderLineage', () => {
},
}
const absent = props(undefined, {}, summaries)
const view = render(<HeaderCatalog {...absent} />)
expect(screen.queryByRole('button')).toBeNull()
const staleEmpty = props(catalog({ entries: [] }), {}, summaries)
view.rerender(<HeaderCatalog {...staleEmpty} />)
const initial = absent.useSessions(value => value)
render(<HeaderCatalog {...absent} useSessions={select => select({
...initial,
projectionsBySession: state === 'absent' ? {} : { [PARENT]: { state, error: null, values: {} } },
})} />)
expect(screen.queryByRole('button')).toBeNull()
expect(absent.refreshProjection).not.toHaveBeenCalled()
})
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
@@ -910,7 +1090,7 @@ describe('SubagentHeaderLineage', () => {
})
})
it('does not derive a subagent address from Session summary lineage', () => {
it('keeps header-derived breadcrumb identity without inventing catalog rows', () => {
const input = {
...props(undefined, {}, {
[CHILD]: {
@@ -922,7 +1102,11 @@ describe('SubagentHeaderLineage', () => {
}
render(<HeaderCatalog {...input} />)
expect(screen.queryByRole('button', { name: '切换子智能体:worker' })).toBeNull()
const switcher = screen.getByRole('button', { name: '切换子智能体:worker' })
hoverCatalog(switcher)
expect(screen.getByText('正在加载子智能体…')).toBeTruthy()
expect(screen.queryAllByRole('treeitem')).toEqual([])
expect(input.refreshProjection).not.toHaveBeenCalled()
})
it('falls back to the catalog session id when the selected row has no label', () => {
@@ -985,21 +1169,20 @@ describe('SubagentHeaderLineage', () => {
it.each([
['ancestor', vi.fn()],
['current', undefined],
] as const)('keeps an absent %s switcher catalog lazy until interaction', (_kind, openTitle) => {
] as const)('keeps the %s title switcher while catalog and list metadata are absent', (_kind, openTitle) => {
const input = {
...props(undefined, {}, {
[CHILD]: {
...summary(CHILD, 1), parentId: PARENT, origin: 'subagent' as const,
displayTitle: '正在扫描项目文件',
},
}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }),
...props(undefined, {}, {}, { parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable' }),
lineageSessionId: CHILD,
displayTitle: '正在扫描项目文件',
...(openTitle === undefined ? {} : { openTitle }),
}
render(<HeaderCatalog {...input} />)
expect(screen.getByRole('button', { name: '切换子智能体:正在扫描项目文件' })).toBeTruthy()
const switcher = screen.getByRole('button', { name: '切换子智能体:正在扫描项目文件' })
expect(input.refreshProjection).not.toHaveBeenCalled()
hoverCatalog(switcher)
expect(screen.getByText('正在加载子智能体…')).toBeTruthy()
expect(screen.queryAllByRole('treeitem')).toEqual([])
expect(input.refreshProjection).not.toHaveBeenCalled()
})
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 6b5c96c5b723c5f8
zh: f8ae5f73860dce45
en: cafc87e8e0ac4058
zh: 4c35bc5bfa24ba6c
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
+2 -2
View File
@@ -51,13 +51,13 @@ Collapsed search is one header action beside the view and add actions: activatin
### Managing sessions
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
Session update times use the tertiary label color, including on archived rows. A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the source Session and uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging.
The keyboard reference exposes New Session, Search sessions, Add workspace, Rename session, Fork session, and Archive session. Desktop defaults use the platform's primary modifier, with Mod+K for search; Windows and macOS Web use the [shortcut service’s platform defaults](../shortcuts/README.md); Linux Web leaves these commands unbound until configured. Button tooltips and Session row menus display the effective bindings. Clicking a menu item targets that row; pressing its shortcut targets the main Session. Desktop and Windows/macOS Web rename use Mod+Alt+G, leaving Mod+Shift+R to browser reload. Rename requires a nonblank main Conversation with no modal obscuring it; a retained Session behind another main panel is unavailable. Other Windows and macOS Desktop bindings also execute from terminal input and modal dialogs; other environments follow the command's region and modal restrictions. Search and rename requests belong to this package; input drafts remain in the browser. The directory picker rejects another open while selection or Workspace adoption is pending. Fork captures the retained main-view Session, for which navigation has requested history opening, and keeps automatic migration. It uses the row action's Host operation to select its last completed turn, without loading older Client history. Missing or blank Sessions are unavailable; the Host rejects a source with no completed turn. A rejected shortcut fork preserves the current selection, displays a localized notification, and can be retried; unexpected failures also retain diagnostic logging.
### Pending interactions
+2 -2
View File
@@ -51,13 +51,13 @@ kind: "package-reference"
### 管理会话
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。
会话更新时间使用 tertiary 文本色,包括已归档行。标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获源会话,并使用会话行相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。
快捷键速查提供新建会话、搜索会话、添加工作区、重命名会话、分叉会话和归档会话。桌面默认使用平台的主修饰键,搜索为 Mod+K;Windows 和 macOS Web 使用[快捷键服务的平台默认值](../shortcuts/README.zh.md);Linux Web 在用户配置前不绑定这些命令。按钮提示和会话行菜单显示当前有效绑定。点击菜单项操作该行,按快捷键操作主会话。Desktop 和 Windows/macOS Web 的重命名均使用 Mod+Alt+G,将 Mod+Shift+R 留给浏览器刷新。重命名要求主区域正在显示非空会话,且没有模态对话框遮挡;切换到其他主面板后,后台保留的会话不可作为重命名目标。其他 Windows 和 macOS Desktop 绑定也可从终端输入区域和模态对话框中执行;其他环境遵循命令的区域和模态限制。搜索和重命名的打开请求归本包管理,输入草稿保留在浏览器中。目录选择或工作区接纳尚未结束时,目录选择器拒绝重复打开。分叉捕获主视图已持有并发起历史打开的会话,保留自动迁移;它使用与行菜单相同的 Host 操作选择最近已完成轮次,不读取更早的 Client 历史。没有会话或会话为空时不可用;没有已完成轮次的源会话由 Host 拒绝。快捷键分叉被拒绝时保留当前选择、显示本地化提示,并允许重试;非预期失败还会保留诊断日志。
### 待处理交互
@@ -295,6 +295,7 @@ export type RowToast =
| { kind: 'unpinFailed' }
| { kind: 'archivedNotOpenable' }
| { kind: 'defaultWorkspaceFailed' }
| { kind: 'forkRequiresOpen'; sessionId: SessionId }
/**
* An explicit New Session request that failed. `message` is untranslated:
* a Host refusal as `code: message` — the stable code stays in the copy so
@@ -411,7 +412,7 @@ export interface SessionRenameDialogInjected {
renameSession: (sessionId: SessionId, title: string) => Promise<void>
}
/** Row toast share: the notice on display, its dismissal, and the two actions the archived notice offers. */
/** Row toast share: the notice, dismissal, archive recovery, and explicit source-history opening. */
export interface RowToastInjected {
hooks: {
/** The notice on display, or none. */
@@ -423,6 +424,8 @@ export interface RowToastInjected {
undoArchive: (sessionId: SessionId) => void
/** Switch the archived filter to "show" so the archived row is back in view. */
showArchived: () => void
/** Open the fork source without retrying the fork. */
openForkSource: (sessionId: SessionId) => void
}
/** Props of the rename dialog entry in `shell.overlay`. */
@@ -18,7 +18,7 @@
import type {} from '@deepseek-ai/dsh-client-product-analytics/client'
import type { Context } from '@deepseek-ai/cordis'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client'
import type { ISessions, SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client'
import type {
IWorkspaces, SessionActivity, WorkspaceArchiveError, WorkspaceSnapshot,
} from '@deepseek-ai/dsh-api-workspace-controller/client'
@@ -211,8 +211,11 @@ export function apply(ctx: Context): void {
forkSession: (sessionId) => {
uiWorkspace.forkSession(sessionId, (childId) => {
ctx.get('productAnalytics')?.track('branch_session_click', { session_id: childId, parent_session_id: sessionId, click_position: 'sidebar' })
}).catch(() => {
// Fork or child-title failure leaves the list as it was.
}, { allowMigration: false }).catch((error: unknown) => {
if (error instanceof Error && error.name === 'SessionForkError'
&& (error as SessionForkError).rpcError.code === 'session/migration-required') {
notify({ kind: 'forkRequiresOpen', sessionId })
}
})
},
})
@@ -227,6 +230,13 @@ export function apply(ctx: Context): void {
dismissToast: () => { rowToast.set(null) },
undoArchive: unarchiveSession,
showArchived: () => { viewInstance.actions.setArchivedFilter('show') },
openForkSource: (sessionId) => {
if (workspaces.list.getSnapshot().archivedSessionIds.includes(sessionId)) {
notify({ kind: 'archivedNotOpenable' })
return
}
openSession(sessionId)
},
})
const browserInjected = (): WorkspaceBrowserInjected => ({
// Explicit group actions keep their target; unscoped New Session inherits
@@ -89,6 +89,8 @@ export const zh = {
'toast.pinFailed': '置顶失败,请稍后重试',
'toast.unpinFailed': '取消置顶失败,请稍后重试',
'toast.createFailed': '新建会话失败:{message}',
'toast.forkRequiresOpen': '请先打开原会话完成迁移,再创建分支',
'toast.openForkSource': '打开原会话',
'sessions.count.one': '{n} 个会话',
'sessions.count.other': '{n} 个会话',
'actions.workspace.aria': '工作区“{name}”的操作',
@@ -210,6 +212,8 @@ export const en = {
'toast.pinFailed': 'Pin failed. Try again later.',
'toast.unpinFailed': 'Unpin failed. Try again later.',
'toast.createFailed': 'New session failed: {message}',
'toast.forkRequiresOpen': 'Open the original session to complete migration before creating a branch.',
'toast.openForkSource': 'Open original session',
'sessions.count.one': '{n} session',
'sessions.count.other': '{n} sessions',
'actions.workspace.aria': 'Workspace actions for {name}',
@@ -51,9 +51,14 @@ export interface UiWorkspace {
* Fork a Session without changing the current selection.
* @param sessionId - source Session.
* @param onCreated - observer before the optional child-title update.
* @param options - optional permission to start source migration; omission preserves the Host default.
* @returns the child SessionId after creation and inherited-title increment.
*/
forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise<SessionId>
forkSession(
sessionId: SessionId,
onCreated?: (childId: SessionId) => void,
options?: Pick<Parameters<ISessions['fork']>[0], 'allowMigration'>,
): Promise<SessionId>
/**
* Resolve the reusable or newly created blank Session for a Workspace.
* @param workspaceId - target Workspace.
@@ -233,8 +238,17 @@ class UiWorkspaceService extends Service implements UiWorkspace {
this.replaceMain(sessionId, navigation, 'reveal', beforeOpen)
}
async forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise<SessionId> {
return this.sessions.fork({ sessionId, increaseTitle: true, ...onCreated === undefined ? {} : { onCreated } })
async forkSession(
sessionId: SessionId,
onCreated?: (childId: SessionId) => void,
options?: Pick<Parameters<ISessions['fork']>[0], 'allowMigration'>,
): Promise<SessionId> {
return this.sessions.fork({
sessionId,
increaseTitle: true,
...onCreated === undefined ? {} : { onCreated },
...options?.allowMigration === undefined ? {} : { allowMigration: options.allowMigration },
})
}
startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void {
@@ -8,24 +8,34 @@ import type { RowToastProps, RowToastState } from '../contract/slots.ts'
/**
* Hold for the notices that take longer to read than a one-line warning: the
* actionable archived notice (two buttons to react to) and a refused Session
* creation, which quotes the Host's reason.
* archive and fork recovery actions, and a refused Session creation quoting the Host's reason.
*/
const LONG_TOAST_HOLD_MS = 6000
/**
* Render the current notice: the archived and stopped-and-archived notices
* with their undo action — plus the show-archived action while archived rows
* are hidden — on a 6 s hold, a refused Session creation with the Host's
* reason on the same hold, or a plain warning for a failed pin, an archived
* row that was clicked, or default Workspace creation.
* @param props - the notice hook, the shared viewing store, the notice dismissal, the two archived-notice actions, and the locale seat.
* Render an operation notice, retaining recovery actions for six seconds.
* @param props - notice and viewing sources, dismissal and recovery callbacks, and localized copy.
* @returns the notice on display, or null.
*/
export function RowActionToast({ useToast, useStore, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
export function RowActionToast({ useToast, useStore, dismissToast, undoArchive, showArchived, openForkSource, t }: RowToastProps) {
const toast = useToast(current => current)
const archivedRowsVisible = useStore(state => (state.archivedFilter ?? 'default') !== 'default')
if (toast === null) return null
if (toast.kind === 'forkRequiresOpen') {
return (
<Toast
key={`toast-${String(toast.seq)}`}
text={t('toast.forkRequiresOpen')}
icon={<IconWarningOutlineRegular />}
holdMs={LONG_TOAST_HOLD_MS}
actions={[{
label: t('toast.openForkSource'),
onClick: () => { dismissToast(); openForkSource(toast.sessionId) },
}]}
onDone={dismissToast}
/>
)
}
if (toast.kind === 'archived' || toast.kind === 'stoppedAndArchived') {
const { sessionId } = toast
return (
@@ -67,7 +77,7 @@ export function RowActionToast({ useToast, useStore, dismissToast, undoArchive,
/** The copy of one plain warning, keyed by the notice kind the union closes over. */
function plainNoticeText(
toast: Exclude<RowToastState, { kind: 'archived' | 'stoppedAndArchived' | 'createFailed' }>,
toast: Exclude<RowToastState, { kind: 'archived' | 'stoppedAndArchived' | 'createFailed' | 'forkRequiresOpen' }>,
t: RowToastProps['t'],
): string {
switch (toast.kind) {
@@ -105,6 +105,7 @@ export function installWorkspaceShortcuts(
if (target === undefined) return { status: 'blocked', reason: t('shortcut.noSession') }
if (target.blank) return { status: 'blocked', reason: t('shortcut.noCompletedTurn') }
return { status: 'handled', run: () => {
// The main view owns history opening for this target.
void navigation.forkSession(target.id).catch((error: unknown) => {
// Client plugin bundles do not share error-class identity.
const unavailable = error instanceof Error && error.name === 'SessionForkError'
@@ -2,8 +2,9 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type {
SessionListState, SessionReference, SessionSummary,
ISessions, SessionListState, SessionReference, SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import { SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
@@ -68,10 +69,23 @@ async function bench() {
value: { items: [{ sessionId: 'session' as never, snippet: 'match' }], hasMore: false },
}))
const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } }))
const binding = vi.fn((_id: string) => ({ session: { rename: renameSession } }))
const history = createSnapshotStore<Pick<SessionSnapshot, 'openState' | 'subagent'>>({ openState: 'open', subagent: null })
const session = { rename: renameSession, getSnapshot: () => history.getSnapshot() }
const binding = vi.fn((_id: string) => ({ session }))
const retainedIds = new Map<string, number>()
const scope = vi.fn((id: string) => retainedIds.has(id) ? ctx : undefined)
const sessionOf = vi.fn((owner: Context) => owner === ctx ? session : undefined)
const retain = vi.fn((target: string) => {
const resolved = binding(target)
const release = vi.fn()
retainedIds.set(target, (retainedIds.get(target) ?? 0) + 1)
let active = true
const release = vi.fn(() => {
if (!active) return
active = false
const count = retainedIds.get(target)! - 1
if (count === 0) retainedIds.delete(target)
else retainedIds.set(target, count)
})
return {
sessionId: target,
binding: resolved,
@@ -85,7 +99,7 @@ async function bench() {
_options: unknown,
operation: (reference: SessionReference) => unknown,
) => await operation(retain(target)))
const fork = vi.fn(async () => 'forked' as never)
const fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
const pinSession = vi.fn(async () => undefined)
const unpinSession = vi.fn(async () => undefined)
// The Host snapshots the injected hooks derive from and a pin's order write
@@ -117,6 +131,8 @@ async function bench() {
search,
searchResultLimit: 20,
binding,
scope,
sessionOf,
subagentAddress: vi.fn(() => undefined),
refreshProjections: vi.fn(() => Promise.resolve()),
fork,
@@ -134,7 +150,7 @@ async function bench() {
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename,
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession,
workspacesSubscribe, initializeDefault,
workspacesSubscribe, initializeDefault, history, scope, sessionOf,
setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot },
setSessions: (snapshot: SessionListState): void => { sessionSnapshot = snapshot },
}
@@ -482,11 +498,17 @@ describe('ui-workspace apply', () => {
// Fork goes through the navigation service, which leaves the child unselected.
const forkSession = vi.spyOn(b.ctx.uiWorkspace, 'forkSession')
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
using source = b.ctx.sessions.retain(sid('session'), { source: 'controllerOperation' })
await source.ready
b.retain.mockClear()
fork.forkSession('session' as never)
await forkSession.mock.results[0]!.value
expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true, onCreated: expect.any(Function) as (childId: SessionId) => void })
const [request] = b.fork.mock.calls[0]!
const { onCreated, ...payload } = request
expect(payload).toEqual({ sessionId: 'session', increaseTitle: true, allowMigration: false })
expect(onCreated).toBeTypeOf('function')
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
// The rename row raises the request the dialog entry reads; settling clears it.
const rename = faceOf(entry(b.slots, MENU_ITEM, 'rename')) as RenameSessionInjected
@@ -521,6 +543,150 @@ describe('ui-workspace apply', () => {
expect(unarchiveSession).toHaveBeenCalledWith('session')
})
it.each(['absent', 'cold', 'loading', 'error', 'closed'] as const)(
'forks a %s source without opening it locally', async (state) => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
if (state !== 'absent') {
const source = b.ctx.sessions.retain(sid('source'), { source: 'controllerOperation' })
onTestFinished(() => { source.release() })
await source.ready
b.history.set({ openState: state === 'closed' ? 'open' : state, subagent: null })
if (state === 'closed') source.release()
}
b.retain.mockClear()
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
expect(() => { fork.forkSession(sid('source')) }).not.toThrow()
await vi.waitFor(() => { expect(b.fork).toHaveBeenCalledOnce() })
const [request] = b.fork.mock.calls[0]!
const { onCreated, ...payload } = request
expect(payload).toEqual({ sessionId: 'source', increaseTitle: true, allowMigration: false })
expect(onCreated).toBeTypeOf('function')
expect(toast.hooks.toast.getSnapshot()).toBeNull()
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
},
)
it('offers to open a source only after the Host refuses migration and never retries the fork', async () => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.fork.mockRejectedValueOnce(new SessionForkError(
new RemoteError('session/migration-required', 'migration required', { sessionId: sid('source') }),
sid('source'),
))
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
fork.forkSession(sid('source'))
await vi.waitFor(() => {
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'forkRequiresOpen', sessionId: 'source', seq: 1 })
})
expect(b.fork).toHaveBeenCalledOnce()
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
toast.dismissToast()
toast.openForkSource(sid('source'))
expect(b.retain).toHaveBeenCalledExactlyOnceWith('source', { source: 'mainView' })
expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith(null)
expect(b.fork).toHaveBeenCalledOnce()
})
it.each(['before the notice', 'after the notice'] as const)(
'keeps a fork source archived %s closed when its Toast action requests opening', async (timing) => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
b.setSessions(sessionState([summary('source', 1)]))
if (timing === 'before the notice') b.setWorkspaces(workspaceState([], [sid('source')]))
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.fork.mockRejectedValueOnce(new SessionForkError(
new RemoteError('session/migration-required', 'migration required', { sessionId: sid('source') }),
sid('source'),
))
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
fork.forkSession(sid('source'))
await vi.waitFor(() => {
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'forkRequiresOpen', sessionId: 'source', seq: 1 })
})
if (timing === 'after the notice') b.setWorkspaces(workspaceState([], [sid('source')]))
toast.dismissToast()
toast.openForkSource(sid('source'))
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'archivedNotOpenable', seq: 2 })
expect(b.retain).not.toHaveBeenCalled()
expect(b.fork).toHaveBeenCalledOnce()
expect(b.selectPanel).not.toHaveBeenCalled()
},
)
it('forks opened read-only subagent history without selecting its child', async () => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
using source = b.ctx.sessions.retain(sid('source'), { source: 'controllerOperation' })
await source.ready
b.history.set({ openState: 'open', subagent: {
address: { parentSessionId: sid('parent'), childSessionId: sid('source'), mode: 'one-shot' },
} })
b.retain.mockClear()
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
fork.forkSession(sid('source'))
await vi.waitFor(() => { expect(b.fork).toHaveBeenCalledOnce() })
const [request] = b.fork.mock.calls[0]!
const { onCreated, ...payload } = request
expect(payload).toEqual({ sessionId: 'source', increaseTitle: true, allowMigration: false })
expect(onCreated).toBeTypeOf('function')
expect(toast.hooks.toast.getSnapshot()).toBeNull()
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
})
it.each(['ordinary', 'other-remote', 'unwrapped-migration'] as const)('keeps a fork failure silent after rejection (%s)', async (kind) => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
using source = b.ctx.sessions.retain(sid('source'), { source: 'controllerOperation' })
await source.ready
b.retain.mockClear()
const error = kind === 'ordinary'
? new Error('fork failed')
: kind === 'other-remote'
? new SessionForkError(
new RemoteError('session/fork-unavailable', 'no completed turn', { sessionId: sid('source') }),
sid('source'),
)
: new RemoteError('session/migration-required', 'migration required', { sessionId: sid('source') })
b.fork.mockRejectedValueOnce(error)
const forkSession = vi.spyOn(b.ctx.uiWorkspace, 'forkSession')
const fork = faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
fork.forkSession(sid('source'))
expect(forkSession).toHaveBeenCalledOnce()
await expect(forkSession.mock.results[0]!.value).rejects.toBe(error)
expect(toast.hooks.toast.getSnapshot()).toBeNull()
expect(b.fork).toHaveBeenCalledOnce()
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
})
it('routes browser actions and picker creation to the services', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
@@ -490,7 +490,10 @@ declare module '@deepseek-ai/dsh-workspace/types' {
describe('RowActionToast', () => {
/** The notice surface over a test-owned notice source; dismissal clears the notice the way apply does. */
function toastSurface(viewState: { archivedFilter?: 'default' | 'show' | 'only' } = { archivedFilter: 'default' }) {
function toastSurface(
viewState: { archivedFilter?: 'default' | 'show' | 'only' } = { archivedFilter: 'default' },
translate = t,
) {
const toast = createSnapshotStore<RowToastState | null>(null)
const instance = createWorkspaceViewStore().create()
// A v5 snapshot hydrates without the filter key; mirror it by dropping the
@@ -502,22 +505,25 @@ describe('RowActionToast', () => {
const dismissToast = vi.fn(() => { toast.set(null) })
const undoArchive = vi.fn()
const showArchived = vi.fn()
const openForkSource = vi.fn()
render(
<RowActionToast
{...overlay}
t={translate}
useToast={bindSnapshotSelector(toast)}
useStore={bindSnapshotSelector(view)}
actions={instance.actions}
dismissToast={dismissToast}
undoArchive={undoArchive}
showArchived={showArchived}
openForkSource={openForkSource}
/>,
)
let seq = 0
const notify = (notice: RowToast): void => {
act(() => { toast.set({ ...notice, seq: ++seq }) })
}
return { dismissToast, undoArchive, showArchived, notify }
return { dismissToast, undoArchive, showArchived, openForkSource, notify }
}
it('renders nothing without a notice', () => {
@@ -526,6 +532,32 @@ describe('RowActionToast', () => {
expect(document.body.textContent).toBe('')
})
it.each([
['zh', t, '请先打开原会话完成迁移,再创建分支', '打开原会话'],
['en', tEn, 'Open the original session to complete migration before creating a branch.', 'Open original session'],
] as const)('keeps the %s fork notice after menu unmount and opens only on its action', (_locale, translate, message, action) => {
const { dismissToast, openForkSource, notify } = toastSurface(undefined, translate)
const { state, setMenuOpen } = openMenu()
const forkSession = vi.fn((sessionId: SessionId) => { notify({ kind: 'forkRequiresOpen', sessionId }) })
const menu = render(<ForkSessionMenuItem {...menuRow(state)} t={translate} forkSession={forkSession} />)
fireEvent.click(screen.getByRole('menuitem', { name: translate('menu.fork') }))
expect(setMenuOpen).toHaveBeenCalledWith(false)
expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(forkSession))
menu.unmount()
expect(screen.queryByRole('menuitem')).toBeNull()
expect(screen.getByRole('alert').textContent).toContain(message)
expect(openForkSource).not.toHaveBeenCalled()
expect(forkSession).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: action }))
expect(dismissToast).toHaveBeenCalledOnce()
expect(openForkSource).toHaveBeenCalledExactlyOnceWith(sid('one'))
expect(callOrder(dismissToast)).toBeLessThan(callOrder(openForkSource))
expect(forkSession).toHaveBeenCalledOnce()
expect(screen.queryByRole('alert')).toBeNull()
})
it('the stopped-and-archived notice offers the same undo and filter actions under its own wording', () => {
const { undoArchive, notify } = toastSurface()
notify({ kind: 'stoppedAndArchived', sessionId: sid('one') })
@@ -554,6 +554,26 @@ describe('UiWorkspaceService', () => {
expect(b.sessions.retained[0]!.release).not.toHaveBeenCalled()
})
it.each([undefined, false, true])('forwards migration permission %s without retaining or selecting a Session', async (allowMigration) => {
const b = bench()
const onCreated = vi.fn<(childId: SessionId) => void>()
b.sessions.fork.mockImplementationOnce(async (options) => {
options.onCreated?.(sid('forked'))
return sid('forked')
})
const options = allowMigration === undefined ? undefined : { allowMigration }
await expect(b.uiWorkspace.forkSession(sid('source'), onCreated, options)).resolves.toBe(sid('forked'))
expect(b.sessions.fork).toHaveBeenCalledExactlyOnceWith({
sessionId: sid('source'), increaseTitle: true, onCreated,
...allowMigration === undefined ? {} : { allowMigration },
})
expect(onCreated).toHaveBeenCalledExactlyOnceWith(sid('forked'))
expect(b.sessions.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
})
it('does not supersede a pending Workspace selection when a sidebar fork completes', async () => {
const b = bench({ workspaces: workspaceState([workspace('a')]) })
const created = Promise.withResolvers<SessionId>()
@@ -202,7 +202,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'refreshProjections(sessionId: SessionId): Promise<void>',
description: 'Load all Session projections once per connection; retry an unsuccessful initial read.',
description: 'Read projections without starting migration; retry failed or migration-deferred reads.',
parameters: [{ name: 'sessionId', description: 'Session to inspect without opening its conversation.' }],
returns: 'completion of the current or newly started refresh.',
},
@@ -213,7 +213,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'bounded results, or a business/transport error.',
},
{
signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void }): Promise<SessionId>',
signature: 'fork(opts: { sessionId: SessionId atSeq?: number increaseTitle?: boolean onCreated?: (childId: SessionId) => void /** False refuses source migration; omission preserves the Host default. */ allowMigration?: boolean }): Promise<SessionId>',
description: 'Fork a session from an exact inclusive prefix of the source; on resolution the child is catalogued and can be explicitly retained.',
parameters: [{ name: 'opts', description: 'source session id, the optional exact inclusive boundary 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. `onCreated` observes the catalogued child before that optional rename.' }],
returns: 'the child session id.',
@@ -348,9 +348,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
throws: ['on failure; a refused creation is also shown through the Workspace notice unless a later navigation or disposal superseded the request.'],
},
{
signature: 'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise<SessionId>',
signature: 'forkSession( sessionId: SessionId, onCreated?: (childId: SessionId) => void, options?: Pick<Parameters<ISessions[\'fork\']>[0], \'allowMigration\'>, ): Promise<SessionId>',
description: 'Fork a Session without changing the current selection.',
parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }],
parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer before the optional child-title update.' }, { name: 'options', description: 'optional permission to start source migration; omission preserves the Host default.' }],
returns: 'the child SessionId after creation and inherited-title increment.',
},
{
@@ -647,6 +647,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ISession',
declaration: 'export interface ISession {\n readonly sessionId: SessionId;\n readonly projections: ProjectionsFace;\n beginSubmission(input: BeginSubmissionInput): SubmissionHandle;\n prompt(content: PromptContentPart[], mode: \'queue\' | \'steer\', signal?: AbortSignal, requestId?: SessionRequestId): Promise<RemoteResult<{\n accepted: true;\n }>>;\n readAttachment(attachmentId: AttachmentIdType): Promise<RemoteResult<{\n attachment: ImageAttachmentRef;\n data: Uint8Array;\n }>>;\n updateQueue(itemId: MessageId, action: QueueAction): Promise<RemoteResult<{\n accepted: true;\n }>>;\n cancel(): Promise<RemoteResult<{\n accepted: true;\n }>>;\n rename(title: string): Promise<RemoteResult<{\n title: string;\n seq: SessionSeq;\n }>>;\n loadOlder(): Promise<void>;\n loadThrough(seq: SessionSeq): Promise<void>;\n command(line: string): Promise<RemoteResult<{\n matched: boolean;\n }>>;\n}',
},
{
name: 'ISessions',
declaration: 'export interface ISessions {\n readonly list: ObservableSnapshot<SessionListState>;\n retain(target: SessionTarget, options: SessionRetainOptions): SessionReference;\n using<T>(target: SessionTarget, options: SessionRetainOptions, operation: (reference: SessionReference) => T | Promise<T>): Promise<T>;\n retainInfo(id: SessionId): ObservableSnapshot<SessionRetainInfo>;\n readonly searchResultLimit: number;\n create(opts?: {\n workspaceId?: WorkspaceId;\n cwd?: string;\n sessionId?: SessionId;\n }): Promise<SessionId>;\n subagentAddress(id: SessionId): SubagentAddress | undefined;\n refreshProjections(sessionId: SessionId): Promise<void>;\n refresh(): Promise<void>;\n search(query: string, signal: AbortSignal): Promise<RemoteResult<{\n items: SessionSearchResultItem[];\n hasMore: boolean;\n }>>;\n fork(opts: {\n sessionId: SessionId;\n atSeq?: number;\n increaseTitle?: boolean;\n onCreated?: (childId: SessionId) => void;\n allowMigration?: boolean;\n }): Promise<SessionId>;\n scope(id: SessionId): AgentContext | undefined;\n scopeOf(ctx: Context): SessionId | undefined;\n sessionOf(ctx: Context): SessionFace | undefined;\n binding(id: SessionId): SessionBinding | undefined;\n}',
},
{
name: 'KeyedHooksSources',
declaration: 'export type KeyedHooksSources = Record<string, KeyedStandardSource>;',
@@ -883,10 +887,22 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionIdOf',
declaration: 'export type SessionIdOf = SessionStandardProps extends {\n sessionId: infer S;\n} ? S : string;',
},
{
name: 'SessionListPhase',
declaration: 'export type SessionListPhase = \'pending\' | \'ready\';',
},
{
name: 'SessionListState',
declaration: 'export interface SessionListState {\n ids: SessionId[];\n byId: Record<SessionId, SessionSummary>;\n phase: SessionListPhase;\n projectionsBySession: Readonly<Record<SessionId, SessionProjectionSnapshot>>;\n}',
},
{
name: 'SessionMaybeStandardProps',
declaration: 'export interface SessionMaybeStandardProps {\n}',
},
{
name: 'SessionProjectionSnapshot',
declaration: 'export interface SessionProjectionSnapshot {\n readonly values: Readonly<Partial<SessionProjectionMap>>;\n readonly state: \'idle\' | \'loading\' | \'ready\' | \'migration-required\' | \'error\';\n readonly error: RemoteFailure | null;\n}',
},
{
name: 'SessionProviderComponent',
declaration: 'export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode;',
@@ -24,7 +24,7 @@ describe('Client Cordis inspect catalog', () => {
.toEqual([
'openSession(target: SessionTarget): void',
'openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void>',
'forkSession(sessionId: SessionId, onCreated?: (childId: SessionId) => void): Promise<SessionId>',
'forkSession( sessionId: SessionId, onCreated?: (childId: SessionId) => void, options?: Pick<Parameters<ISessions[\'fork\']>[0], \'allowMigration\'>, ): Promise<SessionId>',
'connectWorkspace(workspaceId: WorkspaceId): Promise<SessionId>',
'startSession(workspaceId?: WorkspaceId, options?: StartSessionOptions): void',
'archiveSession(sessionId: SessionId, options?: { readonly stopActivity?: boolean }): Promise<void>',
@@ -2031,9 +2031,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
returns: 'the accepted title and durable event sequence.',
},
{
signature: '@Remote(\'fork\') fork(request: SessionForkRequest): Promise<SessionForkValue>',
signature: '@Remote(\'fork\') async fork(request: SessionForkRequest): Promise<SessionForkValue>',
description: '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.',
parameters: [{ name: 'request', description: 'source Session and optional exact inclusive event boundary.' }],
parameters: [{ name: 'request', description: 'source Session, optional exact inclusive event boundary, and migration preflight choice.' }],
returns: 'the new Session identity.',
},
{
@@ -2074,9 +2074,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: '@Remote(\'projections\') async projections(request: SessionProjectionsRequest, signal: AbortSignal): Promise<SessionProjectionsValue>',
description: 'Read all registered projections without activating an Agent.',
parameters: [{ name: 'request', description: 'Session whose current values are required.' }, { name: 'signal', description: 'cancellation for the Session observation.' }],
returns: 'complete baseline, or null when the Session does not exist.',
description: 'Read exact projections, returning cached hints only when migration is required.',
parameters: [{ name: 'request', description: 'Session whose current values are required.' }, { name: 'signal', description: 'cancellation for the Session read.' }],
returns: 'a sequenced baseline, cached hints when migration is deferred, or null when absent.',
},
{
signature: '@Remote({ mode: \'stream\' }) control(signal: AbortSignal): AsyncIterable<SessionControlFrame>',
@@ -6879,7 +6879,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionForkRequest',
declaration: 'export interface SessionForkRequest {\n readonly sessionId: SessionId;\n readonly atSeq?: number;\n}',
declaration: 'export interface SessionForkRequest {\n readonly sessionId: SessionId;\n readonly atSeq?: number;\n readonly allowMigration?: boolean;\n}',
},
{
name: 'SessionForkSource',
@@ -7019,7 +7019,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionPersistenceSnapshot',
declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n}',
declaration: 'export interface SessionPersistenceSnapshot {\n readonly header: SessionHeader;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly revision: SessionPersistenceRevision;\n readonly eventCount?: number;\n readonly sizeBytes?: number;\n}',
},
{
name: 'SessionPersistenceStatOptions',
@@ -7047,7 +7047,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionProjectionsValue',
declaration: 'export type SessionProjectionsValue = SessionProjectionBaseline | null;',
declaration: 'export type SessionProjectionsValue = (SessionProjectionBaseline & {\n readonly kind: \'sequenced\';\n}) | {\n readonly kind: \'migration-required\';\n readonly values: SessionProjectionValues;\n} | null;',
},
{
name: 'SessionProjectionUpdate',
@@ -7071,7 +7071,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n formatStatus?: \'current\' | \'migration-required\';\n}',
},
{
name: 'SessionReferenceCandidate',
@@ -7167,7 +7167,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SessionSummary',
declaration: 'export interface SessionSummary {\n readonly agentAvailable: boolean;\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly projections?: SessionProjectionHints;\n}',
declaration: 'export interface SessionSummary {\n readonly agentAvailable: boolean;\n readonly formatStatus?: \'current\' | \'migration-required\';\n readonly sessionId: SessionId;\n readonly updatedAt: number;\n readonly running: boolean;\n readonly blank: boolean;\n readonly parentSessionId?: SessionId;\n readonly origin?: \'subagent\';\n readonly cwd?: string;\n readonly projections?: SessionProjectionHints;\n}',
},
{
name: 'SessionSurface',
@@ -18,8 +18,8 @@
en: cc9eb8f214c57137
zh: 76723633d0faee5d
/deepseek-ai-dsh-session-query/use-this-package/what-you-can-do:
en: 742b1358d97e56cc
zh: 00d75ce6e5769559
en: 608cd6d4c5cf2c35
zh: 23d3c5a7374b5283
/deepseek-ai-dsh-session-query/use-this-package/filters:
en: 1b95403461947ca1
zh: cffa21b03df9f0ad
@@ -31,7 +31,7 @@ Use `ctx.sessionQuery` from application code when you need to read or search ses
| Operation | What you get |
|---|---|
| `listSessions()` | Every logical session, newest first, with `live` and `persisted` availability flags |
| `listSessions()` | Every logical session, newest first, with `live` and `persisted` availability flags and optional `formatStatus` |
| `readSession(id)` | The complete replay-validated raw event log, without making the session live |
| `filterSessions(filters)` | Sessions matching ANDed metadata and availability predicates |
| `filterEvents(id, filters)` | Semantic event documents matching metadata and literal-text predicates |
@@ -31,7 +31,7 @@ kind: "package-reference"
| 操作 | 你得到什么 |
|---|---|
| `listSessions()` | 每个逻辑会话,最新的在前,带 `live` 与 `persisted` 可用性标志 |
| `listSessions()` | 每个逻辑会话,最新的在前,带 `live` 与 `persisted` 可用性标志及可选 `formatStatus` |
| `readSession(id)` | 经过回放校验的完整原始事件日志,且不会让该会话变为实时 |
| `filterSessions(filters)` | 匹配 AND 连接的元数据与可用性谓词的会话 |
| `filterEvents(id, filters)` | 匹配元数据与字面文本谓词的语义事件文档 |
@@ -3,6 +3,7 @@
import type { Context, Fiber } from '@deepseek-ai/cordis'
import type { Session, SessionEvent, SessionHeader, SessionId , SessionLogOffset } from '@deepseek-ai/dsh-session'
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
import type { SessionRecord } from './types.ts'
import { SessionQueryError } from './config.ts'
import { readColdSessionLog, type ColdSessionLog } from './cold-read.ts'
@@ -64,8 +65,13 @@ export class SessionCorpus {
const persisted = persistence === undefined ? [] : await listPersisted(persistence, signal)
signal?.throwIfAborted()
const records = new Map<SessionId, SessionRecord>()
for (const header of persisted) {
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
for (const { header, formatStatus } of persisted) {
records.set(header.id, {
header: structuredClone(header),
live: false,
persisted: true,
...(formatStatus === undefined ? {} : { formatStatus }),
})
}
for (const session of this._ctx.sessions.list()) {
const durable = records.get(session.id)
@@ -74,6 +80,7 @@ export class SessionCorpus {
header: structuredClone(session.header),
live: true,
persisted: durable !== undefined,
formatStatus: 'current',
})
}
return [...records.values()].sort(compareSessions)
@@ -98,7 +105,7 @@ export class SessionCorpus {
}
const persistence = this._persistence
if (persistence === undefined) throw notFound(sessionId)
const listed = (await listPersisted(persistence, signal)).find(header => header.id === sessionId)
const listed = (await listPersisted(persistence, signal)).find(snapshot => snapshot.header.id === sessionId)
signal?.throwIfAborted()
if (listed === undefined) throw notFound(sessionId)
const loaded = await inspectPersisted(persistence, sessionId, signal)
@@ -109,7 +116,7 @@ export class SessionCorpus {
signal?.throwIfAborted()
return snapshot
}
assertSessionHeadersCompatible(loaded.header, listed)
assertSessionHeadersCompatible(loaded.header, listed.header)
const snapshot = {
header: structuredClone(loaded.header),
inheritedEventCount: loaded.inheritedEventCount,
@@ -156,7 +163,7 @@ export class SessionCorpus {
return orderedResults(ids, resolved)
}
let persisted: SessionHeader[]
let persisted: readonly SessionPersistenceSnapshot[]
try {
persisted = await listPersisted(persistence, signal)
signal?.throwIfAborted()
@@ -167,7 +174,7 @@ export class SessionCorpus {
}
return orderedResults(ids, resolved)
}
const persistedById = new Map(persisted.map(header => [header.id, header]))
const persistedById = new Map(persisted.map(({ header }) => [header.id, header]))
const resolvePersisted = async (sessionId: SessionId): Promise<void> => {
const listed = persistedById.get(sessionId)
if (listed === undefined) {
@@ -257,10 +264,9 @@ function orderedResults<Value>(
async function listPersisted(
persistence: SessionPersistence,
signal?: AbortSignal,
): Promise<SessionHeader[]> {
): Promise<readonly SessionPersistenceSnapshot[]> {
try {
const snapshots = await persistence.list(signal === undefined ? undefined : { signal })
return snapshots.map(snapshot => snapshot.header)
return await persistence.list(signal === undefined ? undefined : { signal })
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw new SessionQueryError(
@@ -31,6 +31,8 @@ export 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'
}
/** One atomic live-preferred observation of a session's current model surface. */
@@ -565,7 +565,7 @@ describe('session-query exact reads', () => {
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
title: 'Live title', eventSeq: 0,
})
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted', 'formatStatus'])
})
it('batches unique persisted title observations through one cancellable corpus scan', async () => {
@@ -918,6 +918,45 @@ describe('session-query exact reads', () => {
expect(older.header.createdAt).toBe(1)
})
it('preserves listed format status with live precedence without opening stored logs', async () => {
const historical = header('format-historical', 1)
const current = header('format-current', 2)
const unknown = header('format-unknown', 3)
const shared = header('format-shared', 4)
TestPersistence.reset()
const ctx = await liveContext()
ctx.sessions.create(shared.id, { meta: { createdAt: shared.createdAt } })
const liveOnly = ctx.sessions.create(SessionId('format-live'), { meta: { createdAt: 5 } })
await ctx.plugin(TestPersistence)
const list = vi.spyOn(ctx.sessionPersistence, 'list').mockResolvedValue([
{ header: historical, revision: SessionPersistenceRevision('historical'), formatStatus: 'migration-required' },
{ header: current, revision: SessionPersistenceRevision('current'), formatStatus: 'current' },
{ header: unknown, revision: SessionPersistenceRevision('unknown') },
{ header: shared, revision: SessionPersistenceRevision('shared'), formatStatus: 'migration-required' },
])
const stat = vi.spyOn(ctx.sessionPersistence, 'stat')
const open = vi.spyOn(ctx.sessionPersistence, 'open')
try {
const records = await ctx.sessionQuery.listSessions()
const byId = new Map(records.map(record => [record.header.id, record]))
expect(byId.get(historical.id)).toMatchObject({ live: false, persisted: true, formatStatus: 'migration-required' })
expect(byId.get(current.id)).toMatchObject({ live: false, persisted: true, formatStatus: 'current' })
expect(byId.get(unknown.id)).toMatchObject({ live: false, persisted: true })
expect(byId.get(unknown.id)).not.toHaveProperty('formatStatus')
expect(byId.get(shared.id)).toMatchObject({ live: true, persisted: true, formatStatus: 'current' })
expect(byId.get(liveOnly.id)).toMatchObject({ live: true, persisted: false, formatStatus: 'current' })
expect(list).toHaveBeenCalledTimes(1)
expect(stat).not.toHaveBeenCalled()
expect(open).not.toHaveBeenCalled()
expect(TestPersistence.readCalls).toEqual([])
} finally {
list.mockRestore()
stat.mockRestore()
open.mockRestore()
await ctx.fiber.dispose()
}
})
it('filters sessions symmetrically and owns mutable filter values immediately', async () => {
const durable = header('durable-filter', 1)
TestPersistence.reset([{ meta: durable, events: eventLog('durable') }])
@@ -1140,7 +1179,7 @@ describe('session-query exact reads', () => {
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
await persistence.dispose()
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([
{ header: shared, live: true, persisted: false },
{ header: shared, live: true, persisted: false, formatStatus: 'current' },
])
})
@@ -30,8 +30,8 @@
en: 1eaa87736cac029b
zh: 26224e8889786d69
/deepseek-ai-dsh-session-persistence-jsonl/use-this-package/reading-the-logs:
en: f6a222ce08f2c1e7
zh: 9c5876f064a657b4
en: 3e53fc90a486d373
zh: 00e6c6e65c56629e
/deepseek-ai-dsh-session-persistence-jsonl/understand-the-implementation:
en: a35407bb25671e7e
zh: 6a00f9510c85adcb
@@ -79,6 +79,8 @@ The current-generation scanner applies the current codec owner’s structural ad
### Reading the logs
Materialized `stat` and `list` snapshots include the format catalog's header classification as `formatStatus`, alongside the translated logical `header`. A read-only preparation leaves the selected historical version unchanged; the status becomes `current` after publication of the successor. Unmaterialized pending Sessions omit `formatStatus`.
`open(id, 'read'|'write')` selects the highest canonical generation. Current input follows the ordinary fast path. For historical input, a read open decodes and migrates the source once, validates the current logical result, and returns it without publishing a successor. A write open reuses that revision-keyed preparation when available, or performs the same preparation, then encodes a same-directory temporary file in bounded chunks, verifies it in a Worker Thread, rechecks the source revision, and publishes the current successor without overwrite before returning. The source remains byte-identical. Source drift after preparation rejects that write open without replacing the logical history already returned to readers; a later write open prepares the new revision. The backend marks decoded event graphs `shared-frozen` when it freezes them before memoization; every nested object and array is frozen, and handle reads and slices preserve that state, including empty slices. Only an unmaterialized pending log reports `detached`. `stat(id)` and `list()` select and translate only the highest generation header without reading event rows or starting migration; snapshots carry the selected file’s `sizeBytes` and a best-effort revision. Current revisions identify that file; historical revisions also fingerprint the selected files across the persistence root, so child changes invalidate cached logical events. Fingerprinting reads filesystem metadata only; unrelated changes conservatively invalidate historical revisions. One `list()` call shares a corpus fingerprint across its historical entries. With `compression: 'none'`, the log is newline-delimited text an external reader can consume directly; the compressed default must be read through the backend.
Verification accepts a successful Worker response only after the Worker exits naturally with code 0. Its concurrency slot remains held through that exit; cancellation and Worker errors terminate and join the Worker before rejecting.
@@ -79,6 +79,8 @@ kind: "package-reference"
### 读取日志
已实体化的 `stat` 与 `list` 快照在转换后的逻辑 `header` 之外,通过 `formatStatus` 保留格式 catalog 的头部分类。只读准备不会改变所选历史版本;发布后继之后,状态才变为 `current`。尚未实体化的待定 Session 省略 `formatStatus`。
`open(id, 'read'|'write')` 选择最高规范 generation。当前格式输入走普通快速路径。对于历史输入,只读 open 会单遍解码并迁移源、校验当前逻辑结果,然后在不发布后继的情况下返回。写 open 会在可用时复用按 revision 为键的 preparation,否则执行同一套 preparation,再按有界分片编码同目录临时文件、在 Worker Thread 中校验、复查源修订,并在返回前以不覆盖方式发布当前后继。源保持逐字节不变。如果源在 preparation 后发生变化,该次写 open 会失败,已经返回给读方的逻辑历史不会被替换;后续写 open 会针对新的 revision 重新执行 preparation。后端在 memo 化前冻结已解码的 event graph,并在此时将其标记为 `shared-frozen`;每个嵌套对象和数组都会冻结,句柄读取和 slice 即使为空也保留该状态。只有尚未实体化的 pending 空日志报告 `detached`。`stat(id)` 与 `list()` 只选择并转换最高 generation 的 header,不读取事件行,也不启动迁移;快照携带所选文件的 `sizeBytes` 与尽力而为的修订号。当前格式修订号标识该文件;历史格式修订号还包含持久化根目录中所选文件的指纹,因此子日志变化会使缓存的逻辑事件失效。指纹只读取文件系统元数据;无关变化也会保守地使历史修订号失效。一次 `list()` 为其历史条目共用一个全库指纹。选择 `compression: 'none'` 后,日志是外部读取方可直接消费的换行分隔文本;压缩默认值必须经后端读取。
校验只有在 Worker 自然以退出码 0 结束后,才接受其成功响应。并发配额一直保留到该次退出;取消或 Worker 错误会先终止并等待 Worker 退出,再使校验失败。
@@ -100,6 +100,12 @@ export interface Config {
compression?: JsonlCompression
}
/** Logical header paired with the catalog's selected physical-generation classification. */
interface GenerationHeader {
readonly header: SessionHeader
readonly formatStatus: NonNullable<SessionPersistenceSnapshot['formatStatus']>
}
/** One stored event graph whose producer has established immutable sharing. */
interface FrozenStoredEvents extends SessionHandleReadResult {
readonly eventState: 'shared-frozen'
@@ -447,13 +453,13 @@ class JsonlSessionPersistence extends SessionPersistence {
}
const selected = await this.findLog(id, options?.signal)
if (selected === undefined) return undefined
const header = await this.readGenerationHeader(selected, id, options?.signal)
if (header === undefined) return undefined
const metadata = await this.readGenerationHeader(selected, id, options?.signal)
if (metadata === undefined) return undefined
try {
const identity = await stat(selected.sourcePath, { bigint: true })
options?.signal?.throwIfAborted()
return {
header,
...metadata,
revision: selected.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${await this.historicalCorpusRevision(options?.signal)}`)
: fileRevision(identity),
@@ -491,6 +497,7 @@ class JsonlSessionPersistence extends SessionPersistence {
listed.add(artifact.header.id)
snapshots.push({
header: artifact.header,
formatStatus: artifact.formatStatus,
revision: artifact.sourceVersion < SESSION_FORMAT_VERSION
? SessionPersistenceRevision(`${fileRevision(identity)}:${corpusRevision}`)
: fileRevision(identity),
@@ -1055,29 +1062,29 @@ class JsonlSessionPersistence extends SessionPersistence {
private async listArtifacts(
signal?: AbortSignal,
): Promise<Array<{ header: SessionHeader; path: string; sourceVersion: number }>> {
): Promise<Array<GenerationHeader & { path: string; sourceVersion: number }>> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
signal?.throwIfAborted()
const artifacts: Array<{ header: SessionHeader; path: string; sourceVersion: number }> = []
const artifacts: Array<GenerationHeader & { path: string; sourceVersion: number }> = []
const ids = new Set<SessionId>()
for (const selected of await this.listGenerations(signal)) {
signal?.throwIfAborted()
let header: SessionHeader | undefined
let metadata: GenerationHeader | undefined
try {
header = await this.readGenerationHeader(selected, undefined, signal)
metadata = await this.readGenerationHeader(selected, undefined, signal)
} catch (error: unknown) {
if (error instanceof SessionFormatUnsupportedError || error instanceof SessionPersistenceCorruptionError) continue
throw error
}
if (header === undefined) {
if (metadata === undefined) {
continue
}
if (ids.has(header.id)) {
throw new Error(`duplicate JSONL session id "${header.id}" appears in multiple project directories`)
if (ids.has(metadata.header.id)) {
throw new Error(`duplicate JSONL session id "${metadata.header.id}" appears in multiple project directories`)
}
ids.add(header.id)
artifacts.push({ header, path: selected.sourcePath, sourceVersion: selected.sourceVersion })
ids.add(metadata.header.id)
artifacts.push({ ...metadata, path: selected.sourcePath, sourceVersion: selected.sourceVersion })
}
signal?.throwIfAborted()
return artifacts
@@ -1088,7 +1095,7 @@ class JsonlSessionPersistence extends SessionPersistence {
selected: ResolvedJsonlGeneration,
expectedId?: SessionId,
signal?: AbortSignal,
): Promise<SessionHeader | undefined> {
): Promise<GenerationHeader | undefined> {
let first: string | undefined
try {
first = this.compression === 'zstd'
@@ -1136,7 +1143,10 @@ class JsonlSessionPersistence extends SessionPersistence {
expectedId,
signal,
)
return header
return {
header,
formatStatus: result.status,
}
}
/** Convert format-catalog string identities to current branded Session metadata. */
@@ -753,11 +753,16 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
await mkdir(dirname(sourcePath), { recursive: true })
await writeFile(sourcePath, source)
readTally.enabled = true
const formatStatus = 'migration-required'
expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({
header: { id: header.id, version: SESSION_FORMAT_VERSION },
formatStatus,
})
const [listed] = await ctx.sessionPersistence.list()
expect(listed?.header).toMatchObject({ id: header.id, version: SESSION_FORMAT_VERSION })
expect(listed?.formatStatus).toBe(formatStatus)
expect(readTally.bySuffix.get(sourcePath) ?? 0).toBe(0)
expect(await readFile(sourcePath)).toEqual(source)
await expect(stat(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
})
@@ -780,6 +785,9 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
const userMessage = restored.events.find(event => event.type === 'user/message')
expect(userMessage).toBeDefined()
expect(Object.isFrozen(userMessage?.data)).toBe(true)
const formatStatus = 'migration-required'
expect((await ctx.sessionPersistence.stat(header.id))?.formatStatus).toBe(formatStatus)
expect((await ctx.sessionPersistence.list()).find(item => item.header.id === header.id)?.formatStatus).toBe(formatStatus)
expect(await readFile(sourcePath)).toEqual(source)
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
expect((await readdir(dirname(sourcePath))).filter(name => name.startsWith('session')).sort())
@@ -989,6 +997,9 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
} else {
await expect(readFile(currentPath)).rejects.toMatchObject({ code: 'ENOENT' })
}
const formatStatus = access === 'write' ? 'current' : 'migration-required'
expect((await ctx.sessionPersistence.stat(header.id))?.formatStatus).toBe(formatStatus)
expect((await ctx.sessionPersistence.list()).find(item => item.header.id === header.id)?.formatStatus).toBe(formatStatus)
expect(await readFile(sourcePath)).toEqual(source)
} finally {
await handle.close()
@@ -1176,6 +1187,24 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
})
})
it('reports format metadata from the highest physical generation without body reads', async () => {
const header = meta('metadata-highest', '/work')
readTally.enabled = true
for (const version of [0, 3, SESSION_FORMAT_VERSION]) {
const path = generationLogPath(root, header.cwd, header.id, version, 'none')
await mkdir(dirname(path), { recursive: true })
const physical = version === 0 ? releasedV0Header(header) : { ...toHeaderLine(header), version }
await writeFile(path, `${JSON.stringify(physical)}\n`)
const formatStatus = version === SESSION_FORMAT_VERSION ? 'current' : 'migration-required'
expect(await ctx.sessionPersistence.stat(header.id)).toMatchObject({
header: { version: SESSION_FORMAT_VERSION }, formatStatus,
})
const [listed] = await ctx.sessionPersistence.list()
expect(listed?.formatStatus).toBe(formatStatus)
expect(readTally.bySuffix.size).toBe(0)
}
})
it('selects a retained future generation above readable historical bytes', async () => {
const header = meta('future-wins', '/work')
const sourcePath = historicalLogPath(root, header.cwd, header.id)
@@ -1187,6 +1216,8 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
await expect(ctx.sessionPersistence.open(header.id, 'read')).rejects.toMatchObject({
name: 'SessionFormatUnsupportedError',
})
await expect(ctx.sessionPersistence.stat(header.id)).rejects.toMatchObject({ name: 'SessionFormatUnsupportedError' })
expect(await ctx.sessionPersistence.list()).toEqual([])
expect(await readFile(sourcePath, 'utf8')).toBe(`${JSON.stringify(releasedV0Header(header))}\n`)
await expect(stat(rawLogPath(root, header.cwd, header.id))).rejects.toMatchObject({ code: 'ENOENT' })
})
@@ -1359,13 +1390,18 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => {
// created session is already visible to this process.
const dir = sessionDir(root, '/work', m.id)
await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
expect((await ctx.sessionPersistence.stat(m.id))?.sizeBytes).toBeUndefined()
const pending = await ctx.sessionPersistence.stat(m.id)
const pendingRows = await ctx.sessionPersistence.list()
expect(pendingRows.map(s => s.header.id)).toContain(m.id)
expect(pending?.sizeBytes).toBeUndefined()
expect(pending?.formatStatus).toBeUndefined()
expect(pendingRows.find(s => s.header.id === m.id)?.formatStatus).toBeUndefined()
await handle.append(oneTurnLog())
expect((await stat(dir)).isDirectory()).toBe(true)
expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
expect((await ctx.sessionPersistence.list()).map(s => s.header.id)).toContain(m.id)
expect((await ctx.sessionPersistence.stat(m.id))?.formatStatus).toBe('current')
await handle.close()
})
@@ -520,7 +520,10 @@ describe('JsonlSessionPersistence: default Zstandard encoding', () => {
buffer[eventFrame.end - 1] = buffer[eventFrame.end - 1]! ^ 0xFF
await writeFile(path, buffer)
expect((await ctx.sessionPersistence.list()).map(item => item.header.id)).toEqual([header.id])
const listed = await ctx.sessionPersistence.list()
expect(listed.map(item => item.header.id)).toEqual([header.id])
expect(listed[0]?.formatStatus).toBe('current')
expect((await ctx.sessionPersistence.stat(header.id))?.formatStatus).toBe('current')
await expect(readAll(ctx.sessionPersistence, header.id)).rejects.toThrow(/frame at byte .* failed validation/)
})
@@ -33,8 +33,8 @@
en: 08b09dce0bcf47c8
zh: b6c5926d6e440a7b
/deepseek-ai-dsh-session-persistence/use-this-package/failures-and-recovery:
en: 6d8beec038b1121a
zh: 1f02952eb2a0b386
en: 18a5cfd4cfa07751
zh: 0c9bf65c4a40d599
/deepseek-ai-dsh-session-persistence/understand-the-implementation:
en: 9e303bd09a71fef1
zh: 1f631925081ae101
@@ -62,6 +62,8 @@ Persistence returns the physically valid log; semantic repair belongs to the rea
### Failures and recovery
For versioned artifacts, `stat` and `list` expose `formatStatus` as `current` or `migration-required`. The separate `header` remains current logical metadata. This status describes storage, not an in-memory preparation or migration task; providers without this information omit it.
A stored log the current build cannot faithfully interpret is refused with a direction-aware error, never misread. `SessionHandle` exposes only current logical records identified by `SESSION_FORMAT_VERSION`; a provider must convert any supported historical storage before returning a handle, and the shipped JSONL provider migrates supported historical generations through its static catalog. A newer format instructs the operator to upgrade the harness. An event type unknown to this build refuses unless its envelope marks it `ignorable`, and committed-prefix corruption rejects as `SessionPersistenceCorruptionError`.
-----
@@ -62,6 +62,8 @@ await ctx.sessionPersistence.flush() // backend-wide d
### 失败与恢复
对于版本化产物,`stat` 与 `list` 通过 `formatStatus` 暴露 `current` 或 `migration-required`。独立的 `header` 仍是当前逻辑元数据。该状态描述存储,不描述内存准备或迁移任务;没有这项信息的提供方省略该字段。
当前构建无法忠实解读的存储日志会被拒绝,并返回指明拒绝方向的错误,绝不会被误读。`SessionHandle` 只暴露由 `SESSION_FORMAT_VERSION` 标识的当前逻辑记录;提供方必须在返回句柄前转换任何受支持的历史存储,随产品交付的 JSONL 提供方会通过静态 catalog 迁移受支持的历史代际。更新的格式会要求操作者升级 harness。本构建不认识的事件类型会被拒绝,除非其信封标记为 `ignorable`;已提交前缀中的损坏以 `SessionPersistenceCorruptionError` 拒绝。
-----
@@ -50,6 +50,8 @@ export {
export 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. */
+6
View File
@@ -880,6 +880,9 @@ importers:
'@deepseek-ai/dsh-remote-mock':
specifier: workspace:*
version: link:../../packages/test-support/remote-mock
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:*
version: link:../../packages/session/session-projection-cache
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:*
version: link:../../packages/subprocess/subprocess-local
@@ -1470,6 +1473,9 @@ importers:
'@deepseek-ai/dsh-session-persistence':
specifier: workspace:*
version: link:../../session/session-persistence
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:*
version: link:../../session/session-persistence-jsonl
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
@@ -0,0 +1,3 @@
- alert:
- text: Open the original session to complete migration before creating a branch.
- button "Open original session"