From 7077d6befcf22b3888e65b355ad09f739312431d Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 3 Aug 2026 16:40:47 +0800 Subject: [PATCH] refactor(tui): resume rows fold titles only, timestamp from artifact mtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rows no longer read logs for anything but the batch title fold: the activity timestamp is a live session's last in-memory event time or the artifact mtime via the optional sessionPersistence.locate(), falling back to creation time; the last-turn, route, and goal columns are gone. Route availability moves to the Enter-time preflight, which already fully reads and replay-validates the one chosen log. The projectSessions public API this PR had added to session-query is reverted — the change is now confined to the TUI package. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 24 ++- ...-31-resume-selector-batch-projection.zh.md | 22 ++- docs/cordis-catalog/services.md | 21 +-- .../session-query.i18n.yaml | 6 +- docs/core-data-structures/session-query.md | 19 -- docs/core-data-structures/session-query.zh.md | 19 -- .../cordis/tool-cordis/src/api-catalog.ts | 12 -- .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 3 +- .../session-query/session-query/README.zh.md | 3 +- .../session-query/session-query/src/index.ts | 28 +-- .../session-query/tests/session-query.spec.ts | 27 --- packages/ui/tui/README.i18n.yaml | 4 +- packages/ui/tui/README.md | 4 +- packages/ui/tui/README.zh.md | 4 +- packages/ui/tui/src/chat/resume.ts | 137 +++++++++------ packages/ui/tui/src/components/dialogs.ts | 90 ++-------- packages/ui/tui/tests/harness.ts | 4 +- ...esume-sessions-all-workspaces.expected.txt | 33 ++-- .../snapshots/resume-sessions.expected.txt | 18 +- packages/ui/tui/tests/tui.snapshot.ts | 6 +- packages/ui/tui/tests/tui.spec.ts | 163 +++++++++++++----- scripts/gen-cordis-catalog.ts | 2 - scripts/type-equiv.manifest.json | 10 -- 25 files changed, 295 insertions(+), 372 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 9eb6325c04..7a51d1081c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: e1777d67b6f1822fd11d2d38bc6fe45ed11b179f -2026-07-31-resume-selector-batch-projection.zh.md: 031293a76afbf88c8da16f61ddef65f891c05382 +2026-07-31-resume-selector-batch-projection.md: 8a0256da34b8d7de94d3f13b06fa41d591543fcf +2026-07-31-resume-selector-batch-projection.zh.md: a0357a06e95d4a7aad2a5646f9bf2b0946d5e167 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index e1777d67b6..8a0256da34 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -1,4 +1,4 @@ -# Agent Note: Resume selector batch projection +# Agent Note: Resume selector folds titles only Status: implemented @@ -6,24 +6,30 @@ English | [中文](2026-07-31-resume-selector-batch-projection.zh.md) ## Problem -Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grows with total log size rather than session count. +Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grew with total log size rather than session count. ## Decision -`SessionQueryService` exposes the existing internal `SessionCorpus.projectMany` batch as public `projectSessions(sessionIds, project, signal?)`: one persistence listing, at most `persistedInspectConcurrency` concurrent persisted inspections, per-id failure isolation, and a synchronous projector over a borrowed `LogicalSessionSource` with no replay validation and no cloning. `readTitleSnapshots` now routes through it; `LogicalSessionSource` and `LogicalProjectionResult` are exported and documented in the session-query core-data-structures page. +Selector rows fold nothing but titles, and everything else a row shows comes from metadata: -The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. +- Titles come from the existing public batch `readTitleSnapshots` — one persistence listing, at most `persistedInspectConcurrency` concurrent inspections, per-id failure isolation. This is the selector's only per-log read; a rejected title read degrades to that row's disabled "Unreadable session" fallback. +- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp. +- The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff. -The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. Closing the overlay aborts the scan through the `AbortSignal` both service methods accept, so a dismissed picker does not keep decompressing a large store; a signal-ignoring backend's late settlement is dropped by a staleness check instead. The finished scan swaps rows in through `setCandidates` (which also clears a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing and projection, so any scan failure closes the overlay and reports the existing failure notice rather than stranding the loading placeholder. +The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder. + +The change is confined to the TUI package: no session-query or session-persistence surface changed. ## Alternatives considered -**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row. +**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged. -**A resume-specific summary method on `sessionQuery`.** Rejected: resume is a TUI concept, and the service seam should not import consumer vocabulary. The generic synchronous projection mirrors the seam `readTitleSnapshots` already used internally and lets the TUI own its fold. +**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications. -**A persisted summary index (e.g. in the SQLite query backend).** Rejected for now: one bounded pass over the store (~1–3 s on the measured machine) is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if stores grow to where one bounded pass is still too slow. +**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times. + +**A persisted summary/title index.** Rejected for now: one bounded title pass is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if title reads over large stores become the bottleneck. ## Consequences -Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. +Opening `/resume` performs one listing, one stat per persisted row, and one bounded title pass instead of N listings and N validated full copies. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 031293a76a..a0357a06e9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 恢复选择器批量投影 +# Agent Note: 恢复选择器只折叠标题 Status: implemented @@ -10,20 +10,26 @@ Status: implemented ## Decision -`SessionQueryService` 将既有的内部 `SessionCorpus.projectMany` 批量能力公开为 `projectSessions(sessionIds, project, signal?)`:一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发持久化检查、按 id 隔离失败,以及一个在借用的 `LogicalSessionSource` 上运行的同步投影函数——不做回放验证也不克隆。`readTitleSnapshots` 现在经由它实现;`LogicalSessionSource` 和 `LogicalProjectionResult` 被导出,并记录在 session-query 核心数据结构页面中。 +选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据: -`/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 +- 标题来自既有的公开批量 `readTitleSnapshots`——一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发检查、按 id 隔离失败。这是选择器唯一的按日志读取;标题读取被拒绝时退化为该行的禁用"Unreadable session"回退。 +- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。 +- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。 -选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。关闭 overlay 会通过两个服务方法都接受的 `AbortSignal` 中止扫描,因此被关闭的选择器不会继续解压大型存储;忽略信号的后端在中止后的迟到结算则由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询与投影共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告既有的失败通知,而不会让加载占位符悬置。 +选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。 + +改动局限于 TUI 包:session-query 与 session-persistence 的任何表面都未改变。 ## Alternatives considered -**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。 +**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。 -**在 `sessionQuery` 上添加恢复专用的摘要方法。** 被拒绝:恢复是 TUI 概念,服务接缝不应引入消费者词汇。通用同步投影复用了 `readTitleSnapshots` 已在内部使用的接缝,并让 TUI 拥有自己的 fold。 +**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。 -**持久化摘要索引(例如放在 SQLite 查询后端中)。** 暂时被拒绝:对存储做一次有界扫描(在测量机器上约 1–3 秒)是可接受的选择器延迟,而索引会引入失效契约。若存储增长到一次有界扫描仍然过慢时再重新引入。 +**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。 + +**持久化摘要/标题索引。** 暂时否决:一次有界标题扫描的选择器延迟可接受,而索引会引入失效契约。若大型存储上的标题读取成为瓶颈再引入。 ## Consequences -打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 +打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat、一次有界标题扫描,而不是 N 次列表查询和 N 份经验证的完整副本。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 556fc6955f..2f67906078 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1462,23 +1462,6 @@ async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise< */ async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise -/** - * Project unique logical sessions synchronously from one cancellable corpus - * observation. - * - * Each source is a borrowed raw log without replay validation or cloning, so - * a batch summary costs one bounded read per persisted session instead of a - * full validated copy; the projector must clone anything it retains beyond - * its own call. Results preserve first-occurrence input order. Operational - * failures stay isolated per session, while cancellation rejects the - * complete operation. - * @param sessionIds - live or persisted session ids to observe. - * @param project - synchronous fold that owns/clones every retained value. - * @param signal - optional cancellation shared by all source reads. - * @returns one fulfilled or rejected result per unique requested id. - */ -async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]> - /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1529,9 +1512,9 @@ async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promi async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` -Types: [LogicalProjectionResult](../core-data-structures/session-query.md) · [LogicalSessionSource](../core-data-structures/session-query.md) · [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:82`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.i18n.yaml b/docs/core-data-structures/session-query.i18n.yaml index fc00c40e0a..f9c7355148 100644 --- a/docs/core-data-structures/session-query.i18n.yaml +++ b/docs/core-data-structures/session-query.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/core-data-structures/session-query.md -session-query.md: cffbd792e8cab6e79365ceb4e0b1d996e7e93cf5 -session-query.zh.md: 2b67761ec3bcd96ee9de15511ec44516f1fe525d +# pnpm run verify-translation-pairing --write +session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e +session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index cffbd792e8..d92af4bac3 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -84,25 +84,6 @@ type SessionTitleObservationResult = } ``` -`projectSessions` batches arbitrary synchronous folds over the same live-preferred corpus: each `LogicalSessionSource` is a borrowed raw log — never replay-validated or cloned — that is valid only for the projector call, so a batch summary costs one bounded read per persisted session. Each `LogicalProjectionResult` settles per unique requested id under the same isolation and cancellation rules as batch title reads. - -```ts type-equiv -/** Borrowed source visible only during one synchronous batch projection. */ -interface LogicalSessionSource { - /** Header selected with `events`; callers must clone retained output. */ - readonly header: SessionHeader - /** Raw events selected with `header`; valid only for the projection call. */ - readonly events: readonly SessionEvent[] -} -``` - -```ts type-equiv -/** One source-projection result in a batch logical-corpus observation. */ -type LogicalProjectionResult = - | { sessionId: SessionId; status: 'fulfilled'; value: Value } - | { sessionId: SessionId; status: 'rejected'; reason: unknown } -``` - ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/docs/core-data-structures/session-query.zh.md b/docs/core-data-structures/session-query.zh.md index 2b67761ec3..ecf330b0a3 100644 --- a/docs/core-data-structures/session-query.zh.md +++ b/docs/core-data-structures/session-query.zh.md @@ -84,25 +84,6 @@ type SessionTitleObservationResult = } ``` -`projectSessions` 在同一实时优先语料库上批量执行任意同步折叠:每个 `LogicalSessionSource` 都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要对每个持久化会话只需一次有界读取。每个 `LogicalProjectionResult` 按唯一请求 id 结算,其失败隔离与取消规则与批量标题读取一致。 - -```ts type-equiv -/** Borrowed source visible only during one synchronous batch projection. */ -interface LogicalSessionSource { - /** Header selected with `events`; callers must clone retained output. */ - readonly header: SessionHeader - /** Raw events selected with `header`; valid only for the projection call. */ - readonly events: readonly SessionEvent[] -} -``` - -```ts type-equiv -/** One source-projection result in a batch logical-corpus observation. */ -type LogicalProjectionResult = - | { sessionId: SessionId; status: 'fulfilled'; value: Value } - | { sessionId: SessionId; status: 'rejected'; reason: unknown } -``` - ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index bb2f5f8102..7091f95447 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -682,10 +682,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise', jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', }, - { - signature: 'async projectSessions( sessionIds: readonly SessionId[], project: (source: LogicalSessionSource) => Value, signal?: AbortSignal, ): Promise[]>', - jsDoc: '/**\n * Project unique logical sessions synchronously from one cancellable corpus\n * observation.\n *\n * Each source is a borrowed raw log without replay validation or cloning, so\n * a batch summary costs one bounded read per persisted session instead of a\n * full validated copy; the projector must clone anything it retains beyond\n * its own call. Results preserve first-occurrence input order. Operational\n * failures stay isolated per session, while cancellation rejects the\n * complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param project - synchronous fold that owns/clones every retained value.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', - }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -2127,14 +2123,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'LlmResolvedModelInfo', declaration: 'export interface LlmResolvedModelInfo extends LlmModelInfo {\n context?: LlmModelContext;\n defaultMaxTokens?: number;\n reasoning?: LlmModelReasoningInfo;\n}', }, - { - name: 'LogicalProjectionResult', - declaration: 'export type LogicalProjectionResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: Value;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};', - }, - { - name: 'LogicalSessionSource', - declaration: 'export interface LogicalSessionSource {\n readonly header: SessionHeader;\n readonly events: readonly SessionEvent[];\n}', - }, { name: 'ManualCompactAgentContext', declaration: 'export interface ManualCompactAgentContext extends CompactAgentContext {\n reserveTurnAdmission(): (() => void) | undefined;\n}', diff --git a/packages/session-query/session-query/README.i18n.yaml b/packages/session-query/session-query/README.i18n.yaml index 0ac53ddb14..fb266a82de 100644 --- a/packages/session-query/session-query/README.i18n.yaml +++ b/packages/session-query/session-query/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session-query/session-query/README.md -README.md: 15ab403100b45e35808e95f84dcd8ab521854c66 -README.zh.md: 5e3cbfa0d13ba4884d0eb2cc1b4506fbfdef2446 +README.md: df97333be3b2c2cf71dd8c9287959bcbd83a5063 +README.zh.md: 1a3df1ce38360975d88a9f578b071b29cefbba0f diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 15ab403100..df97333be3 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,14 +11,13 @@ English | [中文](README.zh.md) - `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. -- `projectSessions(sessionIds, project, signal?)` runs one synchronous caller fold per unique id under the same batched corpus observation, isolation, and cancellation rules as `readTitleSnapshots`. Each source is a borrowed raw log — never replay-validated or cloned — valid only for the projector call, so a batch summary (for example the resume selector) scales with what the projector retains instead of total log size; the projector must clone anything it keeps. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch observation — titles or caller projections — performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each result's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/README.zh.md b/packages/session-query/session-query/README.zh.md index 5e3cbfa0d1..1a3df1ce38 100644 --- a/packages/session-query/session-query/README.zh.md +++ b/packages/session-query/session-query/README.zh.md @@ -11,14 +11,13 @@ - `filterSessions(filters, signal?)` 对同一份克隆逻辑语料库应用与提供方无关的会话元数据和可用性谓词。 - `filterEvents(sessionId, filters)` 提取第一方语义文档,并按 seq 升序应用与提供方无关的元数据和字面文本谓词。 - `readTitleSnapshots(sessionIds, signal?)` 从一次实时优先的语料库观察中解析唯一 id,将取消信号传递给持久化列表查询和检查,并按顺序返回每个会话的结算结果,使某个缺失或格式错误的标题来源不会丢弃其他来源。每个实时来源直接 fold,每个持久化 worker fold 为脱离存储的 header/标题结果,并在出队下一个 id 前释放完整日志。取消会拒绝整个批次。`readTitleSnapshot(sessionId, signal?)` 是单次观察视图;`readTitle(sessionId, signal?)` 只返回其可选的 folded `session/title`。 -- `projectSessions(sessionIds, project, signal?)` 按唯一 id 各执行一次调用方的同步 fold,其批量语料库观察、失败隔离和取消规则与 `readTitleSnapshots` 相同。每个来源都是借用的原始日志——从不做回放验证,也从不克隆——仅在投影函数调用期间有效,因此一次批量摘要(例如恢复选择器)的开销取决于投影函数保留的内容,而不是日志总大小;投影函数必须克隆它要保留的任何值。 - `listEvents(sessionId)` 加载实时优先的原始日志,将每个事件分类为 `current`、`shadowed` 或 `log-only`;该分类使用共享 `dsh-session` 表层 fold。 - `readSurface(sessionId)` 返回一个克隆 header、原始日志捕获边界,以及按模型历史顺序排列的完整折叠后当前表层。实时会话优先于持久化;压缩(compaction)只会在其替换追加之前或之后被观察,绝不会出现合成混合。 - `readEvent(request, signal?)` 返回一个克隆 header、完整目标事件和有界的原始 seq 窗口。`before` 和 `after` 默认为 0,且不得超过 `readWindowMax`。 - `traceSession(sessionId, signal?)` 只读取一次语料库,返回从直接父级向外的祖先,以及确定性的递归后代树。`complete: false` 标识第一个缺失父级;与目标相连的循环会以 `SESSION_QUERY_INVALID_LINEAGE` 失败。 - `traceEvent(request, signal?)` 只加载一次逻辑日志,返回其克隆源 header、直接位置替换和直接已记录来源信息。`replacementChain` 沿位置替换者跟踪到最终替换;来源链接仍不传递。 -持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量观察——标题或调用方投影——执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个结果自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 +持久化是可选的,可动态挂载或卸载。已挂载持久化无法读取时,跨语料库列表和血缘跟踪以 `SESSION_QUERY_PERSISTENCE_FAILED` 失败。针对已知实时会话的标题读取、事件跟踪或事件读取不会查询持久化,因此持久化后端的健康状态无法使当前内存状态变得不可读。持久化标题和事件操作在加载前先执行列表查询,并在元数据不匹配时拒绝,而不会组合不一致的观察。血缘跟踪的取消信号会传递给持久化列表查询;事件跟踪和事件读取的取消信号会传递给持久化列表查询和检查。每项操作都会等待已启动的后端调用结算,然后使用信号的精确原因拒绝,即使后端忽略了该信号。针对已知实时会话且预先中止的标题读取、事件跟踪或事件读取会在 fold 或快照之前拒绝,且不查询持久化。批量标题观察执行一次元数据列表查询,使用最多 `persistedInspectConcurrency` 个 worker 检查唯一持久化 id,并保留每个标题自己观察到的 header,供下游授权使用。取消不会启动已排队检查,且只在已启动 worker 结算后拒绝。`listSessions()` 仍保持轻量,不加载日志或索引标题。 ## 过滤与提取 diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index cf3dd95e39..809971b798 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -36,7 +36,7 @@ import { SessionQueryError, type Config, } from './config.ts' -import { SessionCorpus, type LogicalProjectionResult, type LogicalSessionSource } from './corpus.ts' +import { SessionCorpus } from './corpus.ts' import { buildSessionEventSearchDocuments } from './documents.ts' import { filterSessionEventDocuments, @@ -64,7 +64,6 @@ export { materializeSessionResultFilters, } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' -export type { LogicalProjectionResult, LogicalSessionSource } from './corpus.ts' declare module 'cordis' { interface Context { @@ -206,7 +205,7 @@ export abstract class SessionQueryService extends Service { sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise { - return this.projectSessions(sessionIds, (source): SessionTitleObservation => { + return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => { const title = foldSessionTitle(source.events) return { session: structuredClone(source.header), @@ -215,29 +214,6 @@ export abstract class SessionQueryService extends Service { }, signal) } - /** - * Project unique logical sessions synchronously from one cancellable corpus - * observation. - * - * Each source is a borrowed raw log without replay validation or cloning, so - * a batch summary costs one bounded read per persisted session instead of a - * full validated copy; the projector must clone anything it retains beyond - * its own call. Results preserve first-occurrence input order. Operational - * failures stay isolated per session, while cancellation rejects the - * complete operation. - * @param sessionIds - live or persisted session ids to observe. - * @param project - synchronous fold that owns/clones every retained value. - * @param signal - optional cancellation shared by all source reads. - * @returns one fulfilled or rejected result per unique requested id. - */ - async projectSessions( - sessionIds: readonly SessionId[], - project: (source: LogicalSessionSource) => Value, - signal?: AbortSignal, - ): Promise[]> { - return this._corpus.projectMany(sessionIds, project, signal) - } - /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 0613673f88..2713de9a72 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -548,33 +548,6 @@ describe('session-query exact reads', () => { expect(TestPersistence.inspectSignals).toEqual([signal, signal]) }) - it('projects borrowed raw logs in one corpus scan with per-session failure isolation', async () => { - const persisted = header('project-persisted', 1) - TestPersistence.reset([{ meta: persisted, events: eventLog('persisted-projection') }]) - const ctx = await liveContext() - const live = ctx.sessions.create(SessionId('project-live'), { meta: { createdAt: 2 } }) - live.append('session/title', { - title: 'Live projection', - messageSeqs: [], - source: { kind: 'fallback' }, - }) - await ctx.plugin(TestPersistence) - const missing = SessionId('project-missing') - - const results = await ctx.sessionQuery.projectSessions( - [live.id, persisted.id, missing], - source => ({ id: source.header.id, eventCount: source.events.length }), - ) - - expect(results).toMatchObject([ - { sessionId: live.id, status: 'fulfilled', value: { id: live.id, eventCount: 1 } }, - { sessionId: persisted.id, status: 'fulfilled', value: { id: persisted.id, eventCount: 1 } }, - { sessionId: missing, status: 'rejected' }, - ]) - expect(TestPersistence.listCalls).toBe(1) - expect(TestPersistence.inspectCalls).toEqual([persisted.id]) - }) - it('bounds persisted title inspection concurrency while preserving ordered results', async () => { const entries = Array.from({ length: 12 }, (_, index) => { const meta = header(`bounded-title-${index}`, index) diff --git a/packages/ui/tui/README.i18n.yaml b/packages/ui/tui/README.i18n.yaml index 452ce7a5e7..1715dfa776 100644 --- a/packages/ui/tui/README.i18n.yaml +++ b/packages/ui/tui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/tui/README.md -README.md: 387c7df29f493650ac74649ab60916436f1a9b12 -README.zh.md: be3c757d343ac62703fcdec4912c481077e397e3 +README.md: 7e32af76b3cc7a3d5e99b4a587e66acd31d02496 +README.zh.md: be1cdcc2775536cf442d3e7cccaf0e93eea65ab9 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 387c7df29f..7e32af76b3 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -34,9 +34,9 @@ The footer sums the session's reported usage as `↑ `/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list. -Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Candidates are sorted by last logged activity and searchable by log-backed title or session id, and by workspace label in the all-workspaces scope; each row reports current/live/persisted state, last turn outcome, recent provider/model, and durable goal phase when present. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, a session with no recorded workspace to run in, or a session whose logged provider has no current adapter remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. +Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows fold nothing but each log's title (one bounded batch read): candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory. -Selection repeats those checks and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. +Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`. The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch. diff --git a/packages/ui/tui/README.zh.md b/packages/ui/tui/README.zh.md index be3c757d34..be1cdcc277 100644 --- a/packages/ui/tui/README.zh.md +++ b/packages/ui/tui/README.zh.md @@ -34,9 +34,9 @@ Footer 将会话报告的用量汇总为 `↑`;任 `/resume` 会打开全 viewport 键盘选择器,而非居中对话框。选择器在命令执行时立即打开并接管输入焦点,会话扫描仍在进行时显示加载占位符,直到行数据就绪;Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。 -获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。候选项按最近记录的活动排序,可按日志支持的标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告 current/live/persisted 状态、上一轮次结果、近期提供方/模型,以及存在时的持久目标阶段。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志、没有可运行的已记录工作区的会话,或日志所记提供方没有当前适配器的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 +获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker,使终端 IME 组合保持锚定在字段内。行数据除每份日志的标题(一次有界批量读取)外不折叠任何内容:候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime,再回退到创建时间——可按标题或会话 id 搜索,在所有工作区作用域中还可按工作区标签搜索;每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航,Enter 恢复,Escape 会先清除非空搜索,再次按下才取消,Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。 -选择时会重复这些检查,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 +选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI,并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd,而非恢复出的会话头部,因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标;目标激活仍保持解除,TUI 会要求用户确认或执行 `/goal resume`。 退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index aa211f70ef..6105fb781d 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,16 +1,17 @@ /** * Session-resume sub-controller for the interactive chat channel: the - * `/resume` selector, one batch summary projection that tolerates a corrupt + * `/resume` selector, one metadata-plus-title scan that tolerates a corrupt * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ +import { stat } from 'node:fs/promises' import type { TUI } from '@earendil-works/pi-tui' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' import type { - LogicalSessionSource, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' @@ -66,51 +67,70 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const workspaceLabel = (cwd: string | undefined): string => runtime.formatCwd?.(cwd) ?? formatCwd(cwd) - /** Summarize one record from a borrowed source, retaining only the record and derived scalars. */ + /** Summarize one record from metadata and its batch-folded title. */ const summarize = ( record: SessionRecord, - source: LogicalSessionSource, - providers: ReadonlySet, + title: string | undefined, + lastActivityAt: number | undefined, ): ResumeCandidate => summarizeResumeCandidate( record, - source, + title, + lastActivityAt, agent.session.id, agent.session.header.cwd, - providers, workspaceLabel, ) - /** The disabled fallback row for a session whose log cannot be summarized. */ - const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({ + /** The disabled fallback row for a session whose title read failed. */ + const unreadableCandidate = ( + record: SessionRecord, + lastActivityAt: number | undefined, + error: unknown, + ): ResumeCandidate => ({ record, title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', + lastActivityAt: lastActivityAt ?? record.header.createdAt, currentWorkspace: record.header.cwd === agent.session.header.cwd, workspaceLabel: workspaceLabel(record.header.cwd), disabledReason: `session cannot be loaded: ${errorChain(error)}`, }) - /** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */ - const readResumeCandidate = async ( - record: SessionRecord, - providers: ReadonlySet, - ): Promise => { + /** + * Metadata-only activity time: a live session's last in-memory event time, + * otherwise the persisted artifact's mtime. Never reads a log, so browsing + * cost stays independent of log size; any append (including bookkeeping) + * moves it. + */ + const lastActivityAt = async (record: SessionRecord): Promise => { + const live = ctx.sessions.get(record.header.id) + if (live !== undefined) return live.events.at(-1)?.time + const location = ctx.get('sessionPersistence')?.locate(record.header) + if (location === undefined) return undefined try { - const readQuery = sessionQuery() - /* v8 ignore start -- caller proves the optional service before mapping records */ - if (readQuery === undefined) throw new Error('session query is unavailable') - /* v8 ignore stop */ - const snapshot = await readQuery.readSession(record.header.id) - return summarize(record, { header: snapshot.session, events: snapshot.events }, providers) - } catch (error: unknown) { - return unreadableCandidate(record, error) + return (await stat(location.path)).mtimeMs + } catch { + // Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at. + return undefined } } + /** The latest logged provider/model route, for the preflight availability check. */ + const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => { + const header = events.findLast(item => item.type === 'request/header') + if (header?.type === 'request/header') { + return { provider: header.data.header.config.provider, model: header.data.header.config.model } + } + const assistant = events.findLast(item => item.type === 'assistant/message') + return assistant?.type === 'assistant/message' + ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } + : undefined + } + /** * Re-read every mutable precondition immediately before terminal handoff and - * resolve the exact identity and workspace the host will re-exec into. + * resolve the exact identity and workspace the host will re-exec into. This + * is where the one chosen log is fully read, replay-validated, and checked + * for a currently-available route — the listing never does any of that. */ const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { const query = sessionQuery() @@ -121,17 +141,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId) if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) - const candidate = await readResumeCandidate( - record, - new Set(ctx.llm.listProviders().map(provider => provider.id)), - ) + const candidate = summarize(record, undefined, undefined) if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason) - const cwd = candidate.record.header.cwd + let events: readonly SessionEvent[] + try { + events = (await query.readSession(record.header.id)).events + } catch (error: unknown) { + throw new Error(`session cannot be loaded: ${errorChain(error)}`) + } + const route = resumeRoute(events) + if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) { + throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`) + } + const cwd = record.header.cwd /* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */ if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`) const finalStatus = deps.agentStatus() if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`) - return { id: candidate.record.header.id, cwd } + return { id: record.header.id, cwd } } const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise => { @@ -235,30 +262,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const scanStale = (): boolean => deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted const scanCandidates = async (): Promise => { + // Every workspace in the store is listed; the picker owns the + // current-workspace/all-workspaces scope split over the whole set. const records = await listQuery.listSessions(scanAbort.signal) if (scanStale()) return - // Every workspace in the store is summarized; the picker owns the - // current-workspace/all-workspaces scope split over the whole set. - const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - // One bounded batch projection over borrowed logs: unlike a - // per-candidate readSession, it lists persistence once and skips - // replay validation and log cloning, bounding memory by what each - // summary retains. A corrupt neighbor degrades to one disabled row. - const recordById = new Map(records.map(record => [record.header.id, record])) - const listedRecord = (id: SessionId): SessionRecord => { - const record = recordById.get(id) - /* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */ - if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`) - return record - } - const results = await listQuery.projectSessions( - records.map(record => record.header.id), - source => summarize(listedRecord(source.header.id), source, providers), - scanAbort.signal, - ) - const candidates = results.map(result => result.status === 'fulfilled' - ? result.value - : unreadableCandidate(listedRecord(result.sessionId), result.reason)) + // Rows need only metadata, an mtime, and the batch-folded title — the + // one per-log read the selector performs. A corrupt neighbor degrades + // to one disabled row. + const [titles, activity] = await Promise.all([ + listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal), + Promise.all(records.map(record => lastActivityAt(record))), + ]) + const candidates = records.map((record, index) => { + const title = titles[index] + /* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */ + if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`) + return title.status === 'fulfilled' + ? summarize(record, title.value.title?.title, activity[index]) + : unreadableCandidate(record, activity[index], title.reason) + }) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (scanStale()) return @@ -266,9 +288,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro picker?.setCandidates(candidates) deps.requestRender() } - // One catch covers both stages, so a projection failure cannot strand - // the overlay on its loading placeholder; an aborted scan's rejection - // stays silent because the user already dismissed the picker. + // One catch covers listing, titles, and mtimes, so a scan failure + // cannot strand the overlay on its loading placeholder; an aborted + // scan's rejection stays silent because the user already dismissed the + // picker. void scanCandidates().catch((error: unknown) => { if (scanStale()) return void session.close() diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index a5390a97b7..b6968d1acb 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -23,14 +23,8 @@ import { type AgentLlmTarget, } from '@deepseek-ai/dsh-agent' import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { lastActivityTime } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' -import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' -import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' -import type { - LogicalSessionSource, - SessionRecord, -} from '@deepseek-ai/dsh-session-query' +import type { SessionRecord } from '@deepseek-ai/dsh-session-query' import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts' import { dialogSelectTheme, type Palette } from './theme.ts' @@ -432,97 +426,53 @@ export class ModelDialog implements Component { } } -/** The provider/model route recovered from a resume candidate's log. */ -export interface ResumeRoute { - provider: string - model: string -} - -/** A preflighted resume selector row summarizing one persisted session. */ +/** A resume selector row summarizing one session from metadata and its folded title. */ export interface ResumeCandidate { record: SessionRecord title: string + /** Last observed change: live last-event time or artifact mtime, falling back to creation. */ lastActivityAt: number - lastTurn: string /** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */ currentWorkspace: boolean /** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */ workspaceLabel: string - route?: ResumeRoute - goalPhase?: GoalPhase disabledReason?: string } -function resumeTurnLabel(source: LogicalSessionSource): string { - const event = source.events.findLast(item => item.type === 'turn/end') - if (event === undefined) return 'no completed turn' - const reason = event.data.reason - switch (reason.kind) { - case 'completed': return `turn ${event.data.turn}: completed` - case 'aborted': return `turn ${event.data.turn}: cancelled` - case 'error': return `turn ${event.data.turn}: error` - case 'disposed': return `turn ${event.data.turn}: disposed` - case 'max-tokens': return `turn ${event.data.turn}: max tokens` - case 'interrupted': return `turn ${event.data.turn}: interrupted` - default: return `turn ${event.data.turn}: unknown result` - } -} - -function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined { - const header = source.events.findLast(item => item.type === 'request/header') - if (header?.type === 'request/header') { - return { provider: header.data.header.config.provider, model: header.data.header.config.model } - } - const assistant = source.events.findLast(item => item.type === 'assistant/message') - return assistant?.type === 'assistant/message' - ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } - : undefined -} - /** - * Build one resume selector row from a record and its borrowed log source, - * deriving the title, route, goal phase, workspace scope, and any reason the - * session cannot be resumed here. A workspace other than the current one is a - * scope, not a disabled reason: resuming it hands the process off into that - * directory. The result retains only the record and derived scalars, so a - * borrowed source stays valid for exactly this call. + * Build one resume selector row from a record, its batch-folded title, and a + * metadata-derived activity time, deriving the workspace scope and any reason + * the session cannot be resumed here. A workspace other than the current one + * is a scope, not a disabled reason: resuming it hands the process off into + * that directory. Rows carry no per-log detail beyond the title — route and + * replay validity are checked by the Enter-time preflight against the one + * chosen log. * @param record - The session record. - * @param source - The session's borrowed header and raw event log. + * @param title - The session's batch-folded title, absent for an untitled log. + * @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time. * @param currentId - The current session id. * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. - * @param availableProviders - Providers registered in this runtime. * @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label. * @returns The summarized resume candidate. */ export function summarizeResumeCandidate( record: SessionRecord, - source: LogicalSessionSource, + title: string | undefined, + lastActivityAt: number | undefined, currentId: SessionId, cwd: string | undefined, - availableProviders: ReadonlySet, formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { - const title = foldSessionTitle(source.events)?.title ?? 'Untitled session' - const route = resumeRoute(source) - const foldedGoal = foldGoal(source.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace' - else if (route !== undefined && !availableProviders.has(route.provider)) { - disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})` - } return { record, - title, - // Excludes a prior pickup's boundary, or every browsed session floats up. - lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt, - lastTurn: resumeTurnLabel(source), + title: title ?? 'Untitled session', + lastActivityAt: lastActivityAt ?? record.header.createdAt, currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), - ...route === undefined ? {} : { route }, - /* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */ - ...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase }, ...disabledReason === undefined ? {} : { disabledReason }, } } @@ -601,7 +551,7 @@ export class ResumePicker implements Component, Focusable { private visibleCandidateCount(): number { // The all-workspaces scope adds a per-row workspace line, so a row costs // one more terminal row there than in the single-workspace scope. - const rowHeight = this.scope === 'all' ? 5 : 4 + const rowHeight = this.scope === 'all' ? 4 : 3 const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight)) return Math.min(this.maxVisible, candidateBudget) } @@ -748,11 +698,7 @@ export class ResumePicker implements Component, Focusable { ].filter((value): value is string => value !== undefined).join(' · ') const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}` push(active ? this.palette.bold(this.palette.accent(lead)) : lead) - const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}` - /* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */ - const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}` - push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`)) - push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`)) + push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`)) // Only the all-workspaces scope mixes directories, so the per-row // workspace is redundant in the scope that already names one. if (this.scope === 'all') { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index a62b695b72..3f10d6cfe3 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/packages/ui/tui/tests/harness.ts @@ -69,6 +69,8 @@ export interface TuiHarnessOptions { sessionPersistence?: { list(): Promise load?(id: ReturnType): Promise<{ meta: SessionHeader; events: Session['events'] }> + /** Per-session artifact location for mtime-based activity; defaults to none. */ + locate?(meta: SessionHeader): { kind: string; path: string } | undefined } handoffResume?: TuiRuntime['handoffResume'] /** Host-supplied exit line; absent exercises the no-message path. */ @@ -156,7 +158,7 @@ export async function createTuiTestHarness undefined, + locate: (meta: SessionHeader) => persistence.locate?.(meta), create: () => Promise.resolve(), append: () => Promise.resolve(), load: persistence.load === undefined diff --git a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt index 63ca211c89..9ab0788461 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions-all-workspaces.expected.txt @@ -22,28 +22,25 @@ buffer 8| " " 9| " ❯ Untitled session " style 2-19 fg=bright-magenta bold -10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " - style 2-67 dim -11| " current · live · main-session " - style 2-32 dim -12| " workspace /workspace/project " +10| " 2026-07-23T08:00:00.000Z · current · live · main-session " + style 2-59 dim +11| " workspace /workspace/project " style 2-31 dim -13| " unavailable: current session " +12| " unavailable: current session " style 2-31 fg=yellow -14| " Other workspace work " -15| " 2024-02-02T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -16| " persisted · elsewhere-session " - style 2-32 dim -17| " workspace /workspace/other " +13| " Other workspace work " +14| " 2024-02-02T00:00:00.000Z · persisted · elsewhere-session " + style 2-59 dim +15| " workspace /workspace/other " style 2-29 dim -18| " Resume selector design " -19| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -20| " persisted · earlier-session " - style 2-30 dim -21| " workspace /workspace/project " +16| " Resume selector design " +17| " 2024-01-01T00:00:00.000Z · persisted · earlier-session " + style 2-57 dim +18| " workspace /workspace/project " style 2-31 dim +19| " " +20| " " +21| " " 22| " " 23| " " 24| " " diff --git a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt index 46452aebdb..6f3e87ed43 100644 --- a/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt +++ b/packages/ui/tui/tests/snapshots/resume-sessions.expected.txt @@ -22,17 +22,15 @@ buffer 8| " " 9| " ❯ Untitled session " style 2-19 fg=bright-magenta bold -10| " 2026-07-23T08:00:00.000Z · no completed turn · route unavailable " - style 2-67 dim -11| " current · live · main-session " - style 2-32 dim -12| " unavailable: current session " +10| " 2026-07-23T08:00:00.000Z · current · live · main-session " + style 2-59 dim +11| " unavailable: current session " style 2-31 fg=yellow -13| " Resume selector design " -14| " 2024-01-01T00:00:08.000Z · turn 1: completed · deepseek-official/deepseek-v4-pro " - style 2-83 dim -15| " persisted · earlier-session " - style 2-30 dim +12| " Resume selector design " +13| " 2024-01-01T00:00:00.000Z · persisted · earlier-session " + style 2-57 dim +14| " " +15| " " 16| " " 17| " " 18| " " diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 91d1a67d78..13ddadb879 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -879,12 +879,12 @@ describe('TUI terminal-state snapshots', () => { { type: 'step/end', seq: 5, time: Date.parse(`${day}T00:00:06Z`), data: { turn: 1, step: 1 } }, { type: 'turn/end', seq: 6, time: Date.parse(`${day}T00:00:07Z`), data: { turn: 1, reason: { kind: 'completed' } } }, { type: 'session/title', seq: 7, time: Date.parse(`${day}T00:00:08Z`), data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, - // A prior pickup, dated well after the work: the picker must still - // show the work's date, not the pickup's. - { type: 'session/end-seed', seq: 8, time: Date.parse('2026-07-23T07:59:00.000Z'), data: {} }, ], }) const listGate = Promise.withResolvers() + // Rows show metadata activity (here the created-at fallback: the fake + // store locates no per-session artifact to stat) plus each log's one + // batch-folded title; nothing else is read from the logs. const harness = await setupSnapshot({ sessionPersistence: { list: async () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 03ca90bca2..e780f88624 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' @@ -280,16 +280,19 @@ describe('goodbye message and /resume', () => { { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, ] - /** Derive the selector's batch projection from a fake per-session readSession. */ - const projectViaReadSession = ( + /** Derive the selector's batch title read from a fake per-session readSession. */ + const titlesViaReadSession = ( readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>, - ) => ( - ids: readonly SessionId[], - project: (source: { header: SessionHeader; events: readonly SessionEvent[] }) => unknown, - ) => Promise.all(ids.map(async (sessionId) => { + ) => (ids: readonly SessionId[]) => Promise.all(ids.map(async (sessionId) => { try { const snapshot = await readSession(sessionId) - return { sessionId, status: 'fulfilled', value: project({ header: snapshot.session, events: snapshot.events }) } + const titleEvent = snapshot.events.findLast(event => event.type === 'session/title') + const title = titleEvent?.type === 'session/title' ? { title: titleEvent.data.title } : undefined + return { + sessionId, + status: 'fulfilled', + value: { session: snapshot.session, ...title === undefined ? {} : { title } }, + } } catch (reason) { return { sessionId, status: 'rejected', reason } } @@ -451,7 +454,7 @@ describe('goodbye message and /resume', () => { result.terminal.send('\x1b[6~') await tick() const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) - expect(rendered).toContain('❯ Paged 3') + expect(rendered).toContain('❯ Paged 5') result.terminal.send('\x1b[5~') await tick() expect(result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session'))) @@ -484,26 +487,53 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it.each([ - [{ kind: 'aborted' }, 'cancelled'], - [{ kind: 'error', step: 1, message: 'failed' }, 'error'], - [{ kind: 'disposed' }, 'disposed'], - [{ kind: 'max-tokens' }, 'max tokens'], - [{ kind: 'interrupted' }, 'interrupted'], - [{ kind: 'future-result' } as unknown as TurnEndReason, 'unknown result'], - ] as const)('renders the last turn result %s', async (reason, label) => { - const target = header(`turn-${label}`, 10, '/workspace') + it('orders rows by artifact mtime without reading logs for the timestamp', async () => { + const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-')) + const stale = join(dir, 'stale.log') + const fresh = join(dir, 'fresh.log') + await writeFile(stale, 'x') + await writeFile(fresh, 'x') + await utimes(stale, new Date(1000), new Date(60_000)) + await utimes(fresh, new Date(1000), new Date(120_000)) + // Creation order contradicts mtime order, so the sort proves its source. + const createdLate = header('created-late-touched-early', 50, '/workspace') + const createdEarly = header('created-early-touched-late', 40, '/workspace') + const gone = header('artifact-gone', 30, '/workspace') + const goneTwin = header('artifact-gone-twin', 30, '/workspace') + const paths = new Map([ + [createdLate.id, stale], + [createdEarly.id, fresh], + [gone.id, join(dir, 'missing.log')], + [goneTwin.id, join(dir, 'missing-twin.log')], + ]) + const titles = new Map([ + [createdLate.id, 'Touched early'], + [createdEarly.id, 'Touched late'], + [gone.id, 'Artifact gone'], + [goneTwin.id, 'Artifact gone twin'], + ]) const result = await setup({ cwd: '/workspace', sessionPersistence: { - list: async () => [target], - load: async () => ({ meta: target, events: resumeEvents(`Turn ${label}`, 'deepseek-official', 100, reason) }), + list: async () => [createdLate, createdEarly, gone, goneTwin], + load: async id => ({ + meta: [createdLate, createdEarly, gone, goneTwin].find(target => target.id === id)!, + events: resumeEvents(titles.get(id)!), + }), + locate: meta => ({ kind: 'jsonl', path: paths.get(meta.id)! }), }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain(`turn 1: ${label}`) + const rendered = result.terminal.output.slice(result.terminal.output.lastIndexOf('Resume session')) + expect(rendered).toContain(new Date(120_000).toISOString()) + expect(rendered.indexOf('Touched late')).toBeLessThan(rendered.indexOf('Touched early')) + // A missing artifact falls back to the header's creation time; equal + // times tie-break by id. + expect(rendered).toContain(new Date(gone.createdAt).toISOString()) + expect(rendered.indexOf('artifact-gone')).toBeLessThan(rendered.indexOf('artifact-gone-twin')) + await rm(dir, { recursive: true, force: true }) await dispose(result) }) @@ -537,7 +567,7 @@ describe('goodbye message and /resume', () => { queryCtx = child child.provide('sessionQuery', { listSessions: async () => { listCalls++; return [] }, - projectSessions: async () => [], + readTitleSnapshots: async () => [], } as never) }, }) @@ -579,7 +609,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -617,7 +647,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), - projectSessions: async () => [], + readTitleSnapshots: async () => [], } as never) }, }) @@ -686,7 +716,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise }, - projectSessions: async () => { projections += 1; return [] }, + readTitleSnapshots: async () => { projections += 1; return [] }, } as never) }, }) @@ -698,7 +728,7 @@ describe('goodbye message and /resume', () => { await tick() expect(scanSignal?.aborted).toBe(true) // A signal-ignoring backend can still fulfill after dismissal: the stale - // scan must neither project nor report. + // scan must neither read titles nor report. listing.resolve([]) await tick() expect(projections).toBe(0) @@ -706,14 +736,14 @@ describe('goodbye message and /resume', () => { await dispose(result) }) - it('drops a projection that settles after the picker was dismissed', async () => { + it('drops a title read that settles after the picker was dismissed', async () => { const projecting = Promise.withResolvers() const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: async () => [], - projectSessions: () => projecting.promise, + readTitleSnapshots: () => projecting.promise, } as never) }, }) @@ -730,20 +760,20 @@ describe('goodbye message and /resume', () => { }) it('closes the loading picker and reports a scan that fails after listing', async () => { - const target = header('projection-explodes', 10, '/workspace') + const target = header('titles-explode', 10, '/workspace') const result = await setup({ async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]), - projectSessions: () => Promise.reject(new Error('projection exploded')), + readTitleSnapshots: () => Promise.reject(new Error('titles exploded')), } as never) }, }) result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('Resume session scan failed: projection exploded') + expect(result.terminal.output).toContain('Resume session scan failed: titles exploded') expect(result.terminal.stopped).toBe(0) await dispose(result) }) @@ -762,7 +792,7 @@ describe('goodbye message and /resume', () => { ctx.provide('sessionQuery', { listSessions: () => listing.promise, readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -818,11 +848,12 @@ describe('goodbye message and /resume', () => { result.terminal.send('\r') await tick(); await tick() expect(result.terminal.output).toContain('Missing adapter') - expect(result.terminal.output).toContain('absent-provider/model-1') + // Rows carry no route: availability surfaces only at Enter-time preflight. + expect(result.terminal.output).not.toContain('absent-provider/model-1') expect(result.terminal.output).toContain('Unreadable session') result.terminal.send('Missing adapter') result.terminal.send('\r') - await tick() + await tick(); await tick() expect(result.terminal.output).toContain('route is currently unavailable') expect(result.terminal.stopped).toBe(0) await dispose(result) @@ -847,7 +878,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -862,10 +893,45 @@ describe('goodbye message and /resume', () => { await dispose(result) }) + it('rechecks record liveness at preflight rather than trusting the listed row', async () => { + const target = header('turns-live', 10, '/workspace') + const handoff = vi.fn>() + let listings = 0 + const result = await setup({ + cwd: '/workspace', + handoffResume: handoff, + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Turns live'), + }) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ + header: target, + live: ++listings > 1, + persisted: true, + }]), + readSession, + readTitleSnapshots: titlesViaReadSession(readSession), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('Turns live') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('session is already live in this runtime') + expect(handoff).not.toHaveBeenCalled() + await dispose(result) + }) + it('falls back to assistant provenance and header creation time for sparse logs', async () => { const assistantOnly = header('assistant-route', 20, '/workspace') const empty = header('empty-log', 10, '/workspace') - const events = resumeEvents('Assistant route', 'deepseek-official') + const events = resumeEvents('Assistant route', 'absent-provider') .filter(event => event.type !== 'request/header') .map((event, seq) => ({ ...event, seq })) as SessionEvent[] const result = await setup({ @@ -880,8 +946,23 @@ describe('goodbye message and /resume', () => { result.terminal.send('/resume') result.terminal.send('\r') await tick(); await tick() - expect(result.terminal.output).toContain('deepseek-official/model-1') + // Without a persisted artifact to stat, listing falls back to creation time. expect(result.terminal.output).toContain(new Date(empty.createdAt).toISOString()) + // The preflight route fold falls back to assistant provenance when the + // log carries no request header. + result.terminal.send('Assistant route') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('route is currently unavailable') + // The failed preflight closed the picker; reopen and pick the routeless + // log, which passes the route check — only the absent host stops it. + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + result.terminal.send('empty-log') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('cannot hand it off in place') await dispose(result) }) @@ -975,7 +1056,7 @@ describe('goodbye message and /resume', () => { ctx.provide('sessionQuery', { listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -1013,7 +1094,7 @@ describe('goodbye message and /resume', () => { persisted: true, }]), readSession, - projectSessions: projectViaReadSession(readSession), + readTitleSnapshots: titlesViaReadSession(readSession), } as never) }, }) @@ -1409,10 +1490,6 @@ describe('pi-tui chat lifecycle and transcript', () => { }) expect(result.terminal.output).toContain('Goal restored (active) with automatic continuation disarmed') expect(result.terminal.output).toContain('/goal resume') - result.terminal.send('/resume') - result.terminal.send('\r') - await tick(); await tick() - expect(result.terminal.output).toContain('goal active') await dispose(result) }) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 98331f5568..2ec0b6a4e7 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -55,8 +55,6 @@ export const LINK_MAP: Readonly> = { SessionEvent: 'core.md', SessionId: 'core.md', SessionStartSource: 'core.md', - LogicalProjectionResult: 'session-query.md', - LogicalSessionSource: 'session-query.md', SessionLogSnapshot: 'session-query.md', SessionSurfaceSnapshot: 'session-query.md', ApprovalOutcome: 'approval.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 80b70a4e3e..3d1fcc8f9d 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -459,16 +459,6 @@ "symbol": "SessionTitleObservationResult", "source": "packages/session-query/session-query/src/types.ts" }, - { - "doc": "docs/core-data-structures/session-query.md", - "symbol": "LogicalSessionSource", - "source": "packages/session-query/session-query/src/corpus.ts" - }, - { - "doc": "docs/core-data-structures/session-query.md", - "symbol": "LogicalProjectionResult", - "source": "packages/session-query/session-query/src/corpus.ts" - }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord",