refactor(session-reference): label discovery from projections alone

A title now comes from an attached session's live projection cut or a cold
one's durable checkpoint, and from nothing else. Attachment is decided by the
session store at read time, so a session that attached after the listing is no
longer answered from a checkpoint its log has moved past — the stale-title case
`api-session.list` already handles this way.

The log fold and its per-log memo are gone. Folding one title costs a whole
log, and this call sits under every keystroke; a session no projection answers
for is labeled by its id and regains its title the first time it is opened.

`lib` leaves the default exclusions: Ruby gems and many npm packages keep
sources there, and the miss would be silent and total. A traversal whose root
is unreadable now rejects instead of publishing an empty index over entries
that are still good, which is what the stale-while-revalidate path claimed but
could not do while every readdir error was swallowed. A drill marks the menu
drilled only when its edit actually reached the draft.

Refs #3154
Refs #3180
This commit is contained in:
Yichen Jiang
2026-08-27 15:21:08 +08:00
parent db14361372
commit 8e9da9debf
22 changed files with 248 additions and 380 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-27-web-at-mention-discovery-and-row-content.md
2026-08-27-web-at-mention-discovery-and-row-content.md: 49587a35411952ddb270e2dc2687dcc98b3bebe5
2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 0a26f5ddc2cb1469c2b69c160a134e36cd84586e
2026-08-27-web-at-mention-discovery-and-row-content.md: ae27f98b07d7a837e095f95129b355770fc8ac02
2026-08-27-web-at-mention-discovery-and-row-content.zh.md: 8569473d1dec28f371ae8cc12ed50081d2642ab4
@@ -18,27 +18,29 @@ Web e2e could not see any of this: its scaffold pins an isolated `DSH_HOME` hold
## Decision
**Discovery cost tracks projection-cache coverage.** `SessionReferenceResolver` labels candidates from `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])`, a synchronous in-memory read that `api-session.list` already uses. A session the cache has checkpointed costs no log read. Every other session is folded once and memoized on the resolver for the process lifetime, keyed by the header's creation facts so a reused id cannot inherit a stale title; a session that is attached again is never memoized, because its log is still growing. A non-empty query folds the uncheckpointed remainder before filtering, because the filter reads labels — deferring that fold to the capped page would make a session unfindable by its own title. An empty query filters nothing, so its unresolved tail waits for the page.
**A discovery label is a projection read, never a log read.** `SessionReferenceResolver` asks each listed session's projections for its title and takes its id when none answers. Attachment is decided by the session store at read time, not by the listing that produced the record, so a session that attached in between is never answered from a checkpoint its live log has moved past. An attached session answers from `ctx.sessionProjections.snapshot(session, ['title'])` — the live cut, which advances with every committed event, over events already in memory. A cold one answers from `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])`, the durable checkpoint written when it went cold. Both are synchronous and touch no log.
The cache is optional, and without it the previous fold path stands unchanged, including its limitation that an unfiltered listing folds only its cwd-ranked head.
Folding a title from a log costs the whole log, and this call sits under every keystroke of `@` completion, so it is not attempted at all. A session no projection answers for — one persisted before the cache was composed, or seeded straight to disk — is labeled by its id and cannot be found by its title. That state is self-healing: opening the session once attaches it, and disposal checkpoints it.
**An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to sixteen — version-control and dependency stores plus the build-output basenames of the ecosystems this harness runs in — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides.
**An invalidated file index keeps answering while its replacement builds.** `invalidate()` bumps a counter instead of discarding the traversal. A bare query serves the settled entries and starts a background rebuild that swaps in atomically; only a workspace's first bare query ever waits. A traversal whose root is unreadable rejects rather than settling: an unreadable branch costs its own candidates, but an unreadable root learned nothing, and publishing that as an empty index would replace entries that are still good and leave no invalidation to retry from. A failed refresh leaves the stale entries and the counter behind, so the next query retries. `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` grows from two names to fifteen — version-control and dependency stores plus build-output basenames no ecosystem also uses for sources — and `DEFAULT_FILE_SEARCH_MAX_ENTRIES` rises to 50 000. Both remain `excludedDirectories` and `maxEntries` config fields a deployment overrides.
**Rows carry only what distinguishes them.** A file names its parent directory and nothing at the workspace root. A drilled directory listing names no parent, because its breadcrumb does. A session names its workspace only when `SessionReferenceCandidate.sameWorkspace` is false — the host computes that, since it already holds both working directories for ranking — and is dated from the Host session list's `updatedAt` through the relative-time bucket that list uses, so one session reads the same age on both surfaces. A session the list does not carry falls back to the candidate's `createdAt`. `relativeTime` moves from `ui-workspace`'s `tree.ts` to `ui-primitives`; the words stay in each plugin's own dictionary, per locale-owned copy. The session id leaves the row: it is already the label a session without a title falls back to.
**A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag that says whether a drill or typing produced it. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one.
**A drill publishes a breadcrumb; typing a path does not.** `InputTriggerSource` gains an optional synchronous `header(session, req)` hook returning crumbs, re-polled on every hit with the live query and a pipeline-owned `drilled` flag. The flag is set only when the drill's edit actually reached the draft — a refused edit leaves it clear, so a header never names a directory nobody descended into — and it survives further typing until the menu closes. `CandidateRequest` carries the same flag. Crumbs ride their own snapshot store beside the menu store, so the frozen menu reducer stays unaware of them, and a crumb pick routes through `onPick` with `action: 'drill'` — returning to a step and descending into one are one outcome. `MenuView` renders the header above its scrolling viewport and moves `role="listbox"` onto that viewport, because a breadcrumb is not an option and a listbox may not carry one.
The zh composer placeholder says `文件或对话`, matching the `对话` section title the same menu already shows.
## Alternatives considered
**Trust the projection cache outright: no cache row means no title.** Rejected by measurement and then by a test. Only 154 of 342 sessions in a real store carry a cache record, and 111 of those a title; the rest predate the cache or never checkpointed. Web e2e caught it immediately — a seeded cold session became unfindable by the title in its own log.
**Fold the missing titles from their logs, memoized per cold log.** Implemented first, then removed in review. It made the first filtered query over a corpus the cache had not covered read those logs — on a 342-session store, roughly 190 of them — to rescue sessions that predate the cache. Correlating that store against the cache's arrival showed why the trade is bad: every session the product writes today gets a checkpoint at creation, `turn/end`, and disposal, and an old session acquires one the first time it is opened. The gap is legacy data that heals on contact, not a shape discovery has to pay for on every keystroke.
**Fold the uncheckpointed titles only for the capped page.** Rejected: the filter runs before the page exists, so a title-substring query would skip exactly the sessions whose titles were deferred. The page-only fold survives for the empty-query path, where nothing is filtered.
**Read a cold session's title through `sessionQuery.observeSession` or `persistence.readFrom`.** Rejected: neither removes the read on the shipped backend. `observeSession` borrows the whole `inspection.events`, and `readFrom` documents that sequential media — JSONL, both encodings — "still parse the whole artifact and skip forward"; the primitive bounds what is returned and refolded, not the physical read.
**Debounce the candidate fetch.** Rejected. The reducer already resets every group to pending on each hit, so a trailing debounce extends the skeleton state and reads as *slower* while typing. With the fold removed, the round trip no longer justifies the timer; keeping the previous rows visible under a new generation is a separate decision with pick-safety consequences, and is not taken here.
**Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list covers the measured 41% and stays a config field. A workspace that keeps sources under one of those basenames must override `excludedDirectories`.
**Read `.gitignore` to bound the index.** Rejected for now: it adds an ignore-file parser and a git dependency to a path that must stay synchronous and cheap. A basename list stays a config field a workspace overrides.
**Exclude `lib` by default with the other build outputs.** Rejected: Ruby gems and many npm packages keep their sources there, and the miss would be silent and total rather than the partial truncation this change removes. This repository builds into `lib` and adds it through `excludedDirectories`; the shipped default names only outputs no ecosystem also uses for sources.
**Read the session's last activity on the host, from the `sessionListMetadata` projection.** Rejected: that projection key is declared by `api-session-controller`, so reading it would make a `packages/context` capability depend on the BFF assembly — a direction with no precedent in this repository. The client already holds the same number in `ctx.sessions.list`, which is also what makes the two surfaces agree by construction rather than by coincidence.
@@ -48,7 +50,9 @@ The zh composer placeholder says `文件或对话`, matching the `对话` sectio
## Consequences
A deployment without `session-projection-cache` composed keeps the old cost and the old head-only fold. With it composed, a first query over a corpus the cache has not covered still reads those logs once; the memo makes that a per-log cost rather than a per-keystroke one. A dedicated title index would remove the remainder, and the session-reference README now names that as the open path.
A deployment without `session-projection-cache` composed labels every cold session by its id; without `session-projections` too, every session. Discovery is as complete as the projections it reads, and never slower than them.
A store carrying sessions from before the cache shipped shows those sessions by id until each is opened once. On the machine this change was measured against that is roughly 190 of 342 — visible to a long-time user, invisible to a new one, and shrinking with use.
The file index is one invalidation stale: a bare query answered immediately after a tool result reflects the tree as of the previous traversal, and the following query sees the rebuild. Sources kept under an excluded basename need an `excludedDirectories` override.
@@ -58,6 +62,6 @@ The reference row content is now derived from what the neighbouring chrome alrea
## Testing
Package tests cover the checkpoint path (a filtered query that reads no log), the memoized cold fold and its identity invalidation, the uncheckpointed-tail fold that keeps title filtering complete, stale-while-revalidate including a failed refresh, and the breadcrumb contract from both ends. `reference-composer.e2e.ts` covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare `@`.
Package tests cover a renamed attached session found by its new title while its checkpoint still holds the old one, a cold session labeled from its checkpoint, an unprojected session labeled by its id, a composition with no projection face at all, `readTitleSnapshots` never called on any of those paths, stale-while-revalidate driven through the real filesystem — a root that vanishes under a live index keeps answering and picks the workspace back up when it returns — an unreadable subtree costing only its own candidates, a `lib` tree that stays searchable, and the breadcrumb contract from both ends including a refused drill edit. `reference-composer.e2e.ts` covers the shipped composition: the refreshed menu golden shows the trimmed rows, and a new case drills into a folder, asserts the breadcrumb appears only then, and clicks the root crumb back to a bare `@`. Its seeded sessions appear there as ids, because a seed reaches disk as a log alone and this scaffold seeds after the host has already loaded its projection-cache table; seeding before boot would give the app a populated session list at startup, which the fresh-workspace flow four scenarios share does not expect. The titled paths stay in the package suite, and the e2e asserts the id labels it actually produces rather than a title the fixture cannot carry.
The 1139 ms figure is a measured floor for the server-side I/O against a real store, not an instrumented end-to-end UI latency; the web e2e scaffold's isolated `DSH_HOME` cannot reproduce the corpus that produces it.
@@ -18,27 +18,29 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的
## Decision
**发现成本取决于投影缓存的覆盖率** `SessionReferenceResolver` `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 标注候选,这是一次同步内存读,`api-session.list` 已经在用。缓存已建立 checkpoint 的会话完全不需要读日志。其余会话各折叠一次并按进程生命周期记在 resolver 上,以该 header 的创建事实为键,因此被复用的 id 不会继承过期标题;重新挂载的会话永不被记住,因为它的日志仍在增长。非空查询在过滤之前折叠尚未 checkpoint 的剩余部分,因为过滤读取的正是标签——把这次折叠推迟到截断后的页面,会让一个会话按它自己的标题搜不到。空查询不做过滤,因此它未解析的尾部留给页面处理
**发现用的标签只来自投影读,绝不读日志** `SessionReferenceResolver` 向每个被列出的会话的投影索取标题,无人作答就用它的 id。是否挂载由会话存储在读取时决定,而不是由产生该记录的那次列举决定,因此在两者之间挂载上来的会话绝不会被一份其实时日志已经越过的 checkpoint 作答。已挂载的会话由 `ctx.sessionProjections.snapshot(session, ['title'])` 作答——那是随每个已提交事件推进的实时切面,事件本就在内存里。冷会话由 `ctx.sessionProjectionCache.cachedSnapshot(header, ['title'])` 作答,即它转冷时写下的持久化 checkpoint。两者都是同步的,都不碰日志
缓存是可选的;未组合缓存时,先前的折叠路径原样保留,包括「未经过滤的列表只折叠按 cwd 排序的头部」这一限制
从日志折叠一个标题的代价是整份日志,而这次调用位于 `@` 补全每一次击键之下,所以干脆不做。没有任何投影能作答的会话——早于缓存组合存在的、或被直接 seed 到磁盘的——用 id 作标签,且无法按标题搜到。这个状态会自愈:把该会话打开一次即挂载,销毁时就写下 checkpoint
**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十个——版本控制与依赖目录,加上本 harness 运行的各生态的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories``maxEntries` 配置字段。
**失效的文件索引在替代品构建期间继续作答。** `invalidate()` 递增一个计数器而不是丢弃遍历。裸查询由已完成的条目作答,并启动一次后台重建、完成后原子替换;只有一个工作区的首次裸查询会等待。根目录不可读的遍历会失败而不是落定:不可读的分支只损失它自己的候选,而不可读的根意味着这次遍历什么都没学到,把它作为空索引发布会覆盖掉仍然有效的条目,且不留下任何可供重试的失效标记。失败的刷新保留陈旧条目与计数器,下一次查询因此重试。`DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 从两个名字增至十个——版本控制与依赖目录,加上没有任何生态用作源码目录的构建产物基名——`DEFAULT_FILE_SEARCH_MAX_ENTRIES` 提高到 50 000。两者仍是部署方可覆盖的 `excludedDirectories``maxEntries` 配置字段。
**每一行只承载能区分它的信息。** 文件显示其父目录,位于工作区根目录时不显示。下钻后的目录列表不显示父目录,因为面包屑已经在显示。会话仅在 `SessionReferenceCandidate.sameWorkspace` 为 false 时显示其工作区——由宿主计算,因为排序时它本就同时握有两个工作目录——并用宿主会话列表的 `updatedAt` 经该列表所用的相对时间分档标注时间,因此同一个会话在两处读到的时长一致。列表中没有的会话回落到候选自带的 `createdAt``relativeTime``ui-workspace``tree.ts` 移到 `ui-primitives`;按 locale-owned 文案的规则,词句仍留在各插件自己的字典里。session id 离开行内:它本就是无标题会话回落到的标签。
**下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,后者说明该查询由下钻还是键入产生`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick``action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。
**下钻会发布面包屑,键入路径不会。** `InputTriggerSource` 增加可选的同步 `header(session, req)` 钩子返回面包屑,在每次命中时以实时查询与管线持有的 `drilled` 标记重新询问,该标记只在下钻的编辑真正落到草稿上时才置位——被拒绝的编辑保持清零,因此头部绝不会指向没人进去过的目录——并在菜单关闭前跨越后续键入`CandidateRequest` 携带同一个标记。面包屑走菜单 store 之外的独立快照 store,冻结的菜单归约器因此对它一无所知;点击面包屑经 `onPick``action: 'drill'` 路由——「回到某一步」与「进入某一层」是同一个结果。`MenuView` 把头部渲染在其滚动视口之上,并把 `role="listbox"` 移到该视口上,因为面包屑不是选项,listbox 也不得承载它。
中文 composer placeholder 改为 `文件或对话`,与同一个菜单已经显示的 `对话` 分组标题一致。
## Alternatives considered
**彻底信任投影缓存:没有缓存行就没有标题。** 先被实测否决,再被测试否决。真实存储里 342 会话只有 154 个带缓存记录,其中 111 个带标题;其余早于缓存存在或从未 checkpoint。Web e2e 当场抓到——一个被 seed 的冷会话按它自己日志里的标题搜不到了
**从日志折叠缺失的标题,并按冷日志记忆化。** 先实现了,评审时移除。它会让缓存尚未覆盖的语料在首次过滤查询时读那些日志——在 342 会话的存储上约 190 份——只为救回早于缓存存在的会话。把该存储与缓存的上线时间对照后可以看出这笔买卖不划算:今天产品写出的每个会话都会在创建、`turn/end` 与销毁三处建立 checkpoint,而旧会话只要被打开一次就会补上。缺口是「一碰即愈」的存量数据,不是发现路径每次击键都该付的形状
**只为截断后的页面折叠未 checkpoint 的标题。** 否决:过滤发生在页面存在之前,因此按标题子串查询恰好会跳过那些被推迟折叠的会话。仅页面折叠这一形态保留给空查询路径,那里不做过滤
**通过 `sessionQuery.observeSession` 或 `persistence.readFrom` 读冷会话标题。** 否决:在随附后端上两者都消不掉这次读。`observeSession` 借的是完整的 `inspection.events`;而 `readFrom` 的文档写明顺序介质(JSONL 的两种编码)「仍会解析整个产物再向前跳过」——该原语约束的是返回与重折叠的范围,不是物理读
**给候选拉取加防抖。** 否决。归约器在每次命中时已经把所有分组重置为 pending,因此尾部防抖会延长骨架状态,输入时读起来更慢。折叠成本移除后,往返时间不再值得一个定时器;在新 generation 下保留上一批行是另一个决定,带有误选后果,此处不做。
**读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表覆盖了实测的 41%,且本就是配置字段。把源码放在其中某个基名下的工作区覆盖 `excludedDirectories`
**读 `.gitignore` 来约束索引。** 暂时否决:这会给一条必须保持同步且廉价的路径引入 ignore 文件解析器与 git 依赖。基名列表本就是工作区覆盖的配置字段
**把 `lib` 和其余构建产物一起放进默认排除。** 否决:Ruby gem 与相当一部分 npm 包的源码就在那里,而这次缺失会是无声且彻底的,比本次改动所消除的部分截断更糟。本仓库构建进 `lib`,通过 `excludedDirectories` 自行加上;随附默认值只列没有任何生态用作源码目录的产物名。
**在宿主侧从 `sessionListMetadata` 投影读取会话最近活动时间。** 否决:该投影键由 `api-session-controller` 声明,读取它会让 `packages/context` 的能力依赖 BFF 装配层——本仓库没有这个方向的先例。客户端的 `ctx.sessions.list` 里本就有同一个数字,而这也正是让两处界面「由构造而非由巧合」保持一致的原因。
@@ -48,7 +50,9 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的
## Consequences
未组合 `session-projection-cache` 的部署保持原有成本与原有的仅头部折叠。组合之后,对缓存尚未覆盖的语料,首次查询仍会读一次那些日志;记忆化把它变成按日志一次而不是按击键一次的成本。专用标题索引可以消除剩余部分,session-reference README 现在把它记为开放路径
未组合 `session-projection-cache` 的部署把每个冷会话都标成 id;连 `session-projections` 也没有时,所有会话都是 id。发现能力与它所读的投影一样完整,且绝不会比投影更慢
存有「缓存上线之前的会话」的存储,会把那些会话显示成 id,直到各自被打开一次。在本次实测的机器上约为 342 个里的 190 个——老用户看得见,新用户看不见,且随使用递减。
文件索引落后一次失效:紧接工具结果之后的裸查询反映的是上一次遍历时的目录树,下一次查询才看到重建结果。把源码放在被排除基名下的工作区需要覆盖 `excludedDirectories`
@@ -58,6 +62,6 @@ Web e2e 看不到这一切:它的 scaffold 固定使用只含两个会话的
## Testing
包级测试覆盖 checkpoint 路径(一次不读任何日志的过滤查询)、记忆化冷折叠及其身份失效、保证标题过滤完整的未 checkpoint 尾部折叠、含刷新失败在内的 stale-while-revalidate,以及面包屑契约的两端。`reference-composer.e2e.ts` 覆盖随附组合:刷新后的菜单 golden 显示精简后的行,新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`
包级测试覆盖:被改名的挂载会话在 checkpoint 仍是旧值时按新标题被搜到、冷会话由 checkpoint 标注、无投影可答的会话标成 id、完全没有投影面的组合、以上路径均未调用 `readTitleSnapshots`、经真实文件系统驱动的 stale-while-revalidate——根目录在活索引之下消失时仍继续作答,并在它回来后自动接上——不可读子目录只损失自身候选、`lib` 目录仍可搜索,以及面包屑契约的两端(含被拒绝的下钻编辑)。`reference-composer.e2e.ts` 覆盖随附组合:刷新后的菜单 golden 显示精简后的行,新增用例下钻进入文件夹、断言面包屑只在此时出现、并点击根节点回到裸 `@`。其中被 seed 的会话在那里显示为 id,因为 seed 落到磁盘的只有日志,而该 scaffold 在宿主已载入投影缓存表之后才 seed;把 seed 提前到 boot 之前会让应用启动时就带着一份会话列表,而四个场景共用的「连接新工作区」流程并不预期这一点。带标题的路径留在包级测试里,e2e 断言它真正产生的 id 标签,而不是这个 fixture 承载不了的标题
1139 ms 是针对真实存储的服务端 I/O 实测下限,不是插桩得到的端到端 UI 延迟;web e2e scaffold 的隔离 `DSH_HOME` 无法复现产生该数字的语料。
@@ -6,5 +6,5 @@
- img
- option "reference.txt"
- text: Sessions
- option "Reference order target {{cwd}} · {{age}}"
- option "Research notes {{cwd}} · {{age}}"
- option "reference-order-target-session {{cwd}} · {{age}}"
- option "reference-source-session {{cwd}} · {{age}}"
+14 -9
View File
@@ -157,7 +157,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
expect(snapshot).toContain('Sessions')
expect(snapshot).not.toContain('text: reference Files & folders')
expect(snapshot).toContain('reference.txt')
expect(snapshot).toContain('Research notes')
// A seed reaches disk as a log alone, and the Host labels a session from
// its projections: no checkpoint, so the row is its id. The fixture's own
// title (`Research notes`) is unreachable here by construction, and the
// package suite owns the titled paths.
expect(snapshot).toContain(SOURCE_SESSION_ID)
expect(snapshot).not.toContain('Research notes')
expect(snapshot).not.toContain('text: Subagents')
await input.fill('@reference')
@@ -170,12 +175,12 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await expect.poll(() => fileReference.locator('svg').count()).toBe(1)
await expect.poll(() => input.textContent()).toBe('reference.txt ')
await input.fill('@Research')
await menu.getByRole('option', { name: /Research notes/ }).click()
await input.fill('@reference-source')
await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click()
const sessionReference = page.locator('[data-composer-chip]').last()
await expect.poll(() => sessionReference.textContent()).toBe('Research notes')
await expect.poll(() => sessionReference.textContent()).toBe(SOURCE_SESSION_ID)
await expect.poll(() => sessionReference.locator('svg').count()).toBe(1)
await expect.poll(() => input.textContent()).toBe('Research notes ')
await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} `)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
@@ -195,16 +200,16 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
await input.click()
await page.keyboard.press('ControlOrMeta+A')
await page.keyboard.press('ArrowLeft')
await page.keyboard.type('@Research')
await menu.getByRole('option', { name: /Research notes/ }).click()
await page.keyboard.type('@reference-source')
await menu.getByRole('option', { name: new RegExp(SOURCE_SESSION_ID) }).click()
// Both chips survive the boundary insert: the session chip lands ahead of
// the intact file chip.
const chips = input.locator('[data-composer-chip]')
await expect.poll(() => chips.count()).toBe(2)
await expect.poll(() => chips.first().textContent()).toBe('Research notes')
await expect.poll(() => chips.first().textContent()).toBe(SOURCE_SESSION_ID)
await expect.poll(() => chips.last().textContent()).toBe('reference.txt')
await expect.poll(() => input.textContent()).toBe('Research notes reference.txt ')
await expect.poll(() => input.textContent()).toBe(`${SOURCE_SESSION_ID} reference.txt `)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/session-reference.md
session-reference.md: 66e107266e15a2951020c3152868fc6f5937738c
session-reference.zh.md: 75c98fe8afe5b60e570385583e3520bf3646dfe2
session-reference.md: 429459f37132a379d18251af646181bc29d97d40
session-reference.zh.md: b505498e560c2c5b8f211be7650cc39a16b578c3
+3 -5
View File
@@ -145,11 +145,9 @@ Exact-read consumer that prepares immutable cross-session message context.
/**
* List reference candidates, ranked by working-directory affinity.
*
* A title comes from the projection cache when that cache holds a
* checkpoint for the session; otherwise it is folded from the session's log
* once and remembered for as long as the log stays cold. Without the cache
* composed, only the cwd-ranked head of an unfiltered listing is folded, so
* its tail cannot match a title substring.
* Discovery runs at keystroke rate, so a title only ever comes from a
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
* which sessions can answer one and which fall back to their id.
* @param agent - target agent; self is excluded and its cwd drives ranking.
* @param query - optional case-insensitive session-id/cwd/title substring.
* @param limit - optional positive result cap.
+3 -5
View File
@@ -145,11 +145,9 @@ Exact-read consumer that prepares immutable cross-session message context.
/**
* List reference candidates, ranked by working-directory affinity.
*
* A title comes from the projection cache when that cache holds a
* checkpoint for the session; otherwise it is folded from the session's log
* once and remembered for as long as the log stays cold. Without the cache
* composed, only the cwd-ranked head of an unfiltered listing is folded, so
* its tail cannot match a title substring.
* Discovery runs at keystroke rate, so a title only ever comes from a
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
* which sessions can answer one and which fall back to their id.
* @param agent - target agent; self is excluded and its cwd drives ranking.
* @param query - optional case-insensitive session-id/cwd/title substring.
* @param limit - optional positive result cap.
@@ -485,10 +485,13 @@ export class InputTriggerController {
})
this.stopFetch()
this.reduce({ type: 'close' })
// After the close above, so the reducer's own teardown cannot clear it:
// the drilled query arrives on the next track() call.
this.drilled = action === 'drill'
this.execute(outcome, hit.span)
const applied = this.execute(outcome, hit.span)
// Set after the close above, so the reducer's own teardown cannot clear
// it, and only when the descent text actually landed: a refused edit
// (stale draft revision, or no listener) leaves the draft where it was,
// and a header over that draft would name a directory nobody descended
// into while hiding the locations its rows still need.
this.drilled = action === 'drill' && applied
}
/** Re-poll every header-bearing source in the hit roster and publish their crumbs. */
@@ -81,9 +81,10 @@ export interface HeaderRequest {
/** Whether the active @file token is an open quoted path. */
readonly quoted?: boolean
/**
* True while the open menu was reached by a drill pick rather than typed.
* The pipeline owns this fact; what it means for a header is the source's
* to decide.
* True while this menu was opened or last re-scoped by a drill pick. It
* survives further typing and clears when the menu closes, so a query typed
* after a drill still reads as drilled. The pipeline owns the fact; what it
* means for a header is the source's to decide.
*/
readonly drilled: boolean
}
@@ -104,7 +105,7 @@ export interface CandidateRequest {
/** Whether the active @file token is an open quoted path. */
readonly quoted?: boolean
readonly position: TriggerPosition
/** Whether the open menu was reached by a drill pick rather than typed. */
/** Whether this menu was opened or last re-scoped by a drill pick; see {@link HeaderRequest.drilled}. */
readonly drilled: boolean
readonly signal: AbortSignal
}
@@ -626,7 +626,8 @@ describe('header / drilled descent', () => {
it('routes a crumb through the source drill path and refuses the current step', async () => {
const { source, picks } = crumbSource()
const { controller } = controllerBench([source])
const { controller, actx } = controllerBench([source])
actx.on('slash/input-insert-text', () => true)
controller.track('@sr', 3, { tier: 'plain' }, 1)
await tick()
controller.pick('reference', 0, 'drill')
@@ -640,6 +641,18 @@ describe('header / drilled descent', () => {
expect(picks[0]).toMatchObject({ candidate: { name: 'src', value: 'src' }, action: 'drill', via: 'menu' })
})
it('publishes no crumbs when the input refused the drill edit', async () => {
const { source } = crumbSource()
const { controller } = controllerBench([source])
// No listener accepts the insert, so the descent text never landed.
controller.track('@sr', 3, { tier: 'plain' }, 1)
await tick()
controller.pick('reference', 0, 'drill')
controller.track('@src/', 5, { tier: 'plain' }, 2)
await tick()
expect(controller.headers.getSnapshot().size).toBe(0)
})
it('drops a source whose header throws and keeps the rest of the menu', async () => {
const failing: InputTriggerSource = {
trigger: '@',
@@ -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/context/file-reference-local/README.md
README.md: 7a0fff671f7ecc8b48b16e86ea71a3c0071a0e38
README.zh.md: 5b01e7c9b10676f89aecdc6a74c5ea5b8b155f3e
README.md: da5db0882e9f4538bdd901a970eca98aac10bbde
README.zh.md: cea38d335c8b29ddc3c875782cb62df1d0ae6e0d
@@ -47,7 +47,7 @@ Typing `@` in a host UI returns up to `maxResults` ranked path candidates for th
|---|---|---|
| `maxResults` | `20` | Maximum ranked candidates returned for one query |
| `maxEntries` | `50000` | Maximum files and directories indexed per agent workspace |
| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates |
| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | Directory basenames omitted from traversal and candidates |
Every numeric value must be a positive safe integer, and every excluded name must be a non-empty basename without `/` or `\`.
@@ -75,7 +75,7 @@ The provider maintains one reusable `WorkspaceFileSearch` per agent, rooted at t
### Main flow
A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree; unreadable or excluded subtrees contribute no candidates.
A `list(agent, query, signal)` call either lists one directory's entries or reads the shared bounded index, ranks the candidates (exact, prefix, substring, then subsequence scores with directory bonuses), and returns at most `maxResults` in deterministic order. `tool/result` events mark the addressed agent's index stale so a later bare query observes a fresh tree. An unreadable or excluded subtree contributes no candidates, while an unreadable root fails its traversal instead: a transient failure must not replace still-good entries with an empty index.
</details>
@@ -124,7 +124,7 @@ The stable sentence joins the system-prompt prefix. Mounting or removing this pr
These limits define when the provider is a poor fit. They are current package constraints.
- **Host-local namespace** — the provider scans the Harness host filesystem, so remote or virtual `read` implementations require a provider whose namespace matches the tool.
- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name build outputs by convention, so a workspace that keeps sources under one of those basenames must override `excludedDirectories`.
- **Bounded advisory index** — very large workspaces may omit paths after `maxEntries`, and excluded or unreadable directories do not appear. The default exclusions name only build outputs no ecosystem also uses for sources; `lib` is deliberately absent, so a workspace that builds into it adds that name through `excludedDirectories`.
- **One invalidation of staleness** — a bare query answered right after a tool result reflects the tree as of the previous traversal; the following query sees the rebuild.
- **No ignore-file semantics** — `.gitignore` and other project ignore files do not influence discovery; only configured directory basenames are excluded.
@@ -47,7 +47,7 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选
|---|---|---|
| `maxResults` | `20` | 单次查询返回的排序候选最大数量 |
| `maxEntries` | `50000` | 每个 agent 工作区建立索引的文件与目录最大数量 |
| `excludedDirectories` | `['.git', 'node_modules', 'lib', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 |
| `excludedDirectories` | `['.git', 'node_modules', 'dist', 'build', 'out', 'coverage', 'target', '.next', '.nuxt', '.turbo', '.venv', '__pycache__', '.pytest_cache', '.mypy_cache', '.gradle']` | 遍历与候选中排除的目录基名 |
所有数值都必须是正的安全整数,所有排除名都必须是不含 `/``\` 的非空基名。
@@ -75,7 +75,7 @@ agent(智能体)及其宿主 UI 获得 `@file` mention 的排序路径候选
### 主要流程
`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树不可读或已排除的子目录不贡献候选。
`list(agent, query, signal)` 要么列出某个目录的条目,要么读取共享的有界索引,对候选排序(精确、前缀、子串,再到子序列得分,目录有加成),并按确定性顺序返回至多 `maxResults` 个。`tool/result` 事件把指定 agent 的索引标记为陈旧,之后的裸查询因此观察到全新目录树不可读或已排除的子目录不贡献候选,而不可读的根目录则让该次遍历失败:一次瞬时故障不得用空索引覆盖仍然有效的条目
</details>
@@ -124,7 +124,7 @@ Tokens prefixed with @ are workspace paths the user explicitly referenced, relat
这些限制说明该提供方何时不合适。它们是当前包约束。
- **宿主本地命名空间**:提供方扫描 Harness 宿主的文件系统,因此远程或虚拟 `read` 实现需要使用命名空间与该工具一致的提供方。
- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项按惯例命名构建产物,若工作区把源码放在其中某个基名下,需覆盖 `excludedDirectories`
- **有界的提示性索引**:超大型工作区可能省略 `maxEntries` 之后的路径;被排除或无法读取的目录不会出现。默认排除项只列没有任何生态用作源码目录的构建产物;`lib` 被刻意排除在外,因此构建进 `lib` 的工作区需通过 `excludedDirectories` 自行加上
- **一次失效的陈旧窗口**:紧接工具结果之后的模糊查询反映的是上一次遍历时的目录树;下一次查询才看到重建结果。
- **没有忽略文件语义**`.gitignore` 和其他项目忽略文件不会影响发现;系统只排除已配置的目录基名。
@@ -18,15 +18,19 @@ export const DEFAULT_FILE_SEARCH_MAX_RESULTS = 20
export const DEFAULT_FILE_SEARCH_MAX_ENTRIES = 50_000
/**
* Directory basenames omitted from traversal unless the deployment overrides
* them: version-control and dependency stores plus the build outputs of the
* ecosystems this harness runs in. Generated files carry the basenames of the
* them: version-control and dependency stores plus build-output names that no
* ecosystem also uses for sources. Generated files carry the basenames of the
* sources that produced them, so an unfiltered tree both spends the entry
* budget twice and ranks `lib/x.js` beside `src/x.ts` for every query.
* budget twice and ranks `dist/x.js` beside `src/x.ts` for every query.
*
* `lib` is deliberately absent: Ruby gems and many npm packages keep their
* sources there, and excluding it would make `@` miss those sources entirely
* and silently. A workspace that builds into `lib` adds it through
* `excludedDirectories`.
*/
export const DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES = [
'.git',
'node_modules',
'lib',
'dist',
'build',
'out',
@@ -204,7 +208,13 @@ export class WorkspaceFileSearch {
if (directory === undefined) {
throw new Error('file search selected a missing directory')
}
const entries = await readDirectory(directory.absolute, signal)
// The root is not a subtree: an unreadable branch costs its own
// candidates, but an unreadable root means the traversal learned
// nothing. Letting that settle would publish an empty index over
// entries that are still good and leave no invalidation to retry from.
const entries = cursor === 0
? await readWorkspaceRoot(directory.absolute, signal)
: await readDirectory(directory.absolute, signal)
for (const entry of entries) {
signal.throwIfAborted()
const path = directory.relative === '' ? entry.name : `${directory.relative}/${entry.name}`
@@ -271,6 +281,13 @@ async function resolveDisplayDirectory(
return absolute
}
async function readWorkspaceRoot(absolute: string, signal: AbortSignal) {
signal.throwIfAborted()
const entries = await readdir(absolute, { withFileTypes: true })
signal.throwIfAborted()
return entries.sort((left, right) => compareText(left.name, right.name))
}
async function readDirectory(absolute: string, signal: AbortSignal) {
signal.throwIfAborted()
try {
@@ -1,4 +1,4 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -11,6 +11,8 @@ import {
const searches: WorkspaceFileSearch[] = []
const roots: string[] = []
/** Permission-stripped directories; restored before cleanup can remove them. */
const locks: string[] = []
async function workspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'dsh-file-autocomplete-'))
@@ -45,6 +47,7 @@ function search(root: string, overrides: Partial<ConstructorParameters<typeof Wo
}
afterEach(async () => {
for (const locked of locks.splice(0)) await chmod(locked, 0o700).catch(() => undefined)
for (const instance of searches.splice(0)) instance.dispose()
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
})
@@ -172,36 +175,45 @@ describe('WorkspaceFileSearch', () => {
files.dispose()
})
it('keeps the stale entries when a refresh fails and retries on the next query', async () => {
it('keeps the stale entries when the workspace root is unreadable, and retries once it returns', async () => {
const root = await workspace()
const files = search(root)
const signal = new AbortController().signal
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
// A root that vanishes under a live index: an unreadable branch costs its
// own candidates, but an unreadable root must not be published as an
// empty workspace over entries that are still good.
await rm(root, { recursive: true, force: true })
files.invalidate()
const scan = vi
.spyOn(files as unknown as { scanWorkspace: () => Promise<unknown> }, 'scanWorkspace')
.mockRejectedValueOnce(new Error('scan failed'))
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
await vi.waitFor(() => { expect(scan).toHaveBeenCalledTimes(1) })
scan.mockRestore()
// The failed attempt left the index stale, so the next query starts a new one.
await writeFile(join(root, 'retried.ts'), 'retried')
expect(await files.list('retried', signal)).toEqual([])
await new Promise((resolve) => { setTimeout(resolve, 50) })
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
// The failed attempt left the index stale, so its return is picked up
// without waiting for another invalidation.
await mkdir(root, { recursive: true })
await writeFile(join(root, 'restored.ts'), 'restored')
await vi.waitFor(async () => {
expect(await files.list('retried', signal)).toEqual([{ path: 'retried.ts', kind: 'file' }])
expect(await files.list('restored', signal)).toEqual([{ path: 'restored.ts', kind: 'file' }])
})
})
it('keeps nothing from a traversal that settles after disposal', async () => {
it('lets an unreadable subtree cost only its own candidates', async () => {
const root = await workspace()
const locked = join(root, 'locked')
await mkdir(locked, { recursive: true })
await writeFile(join(locked, 'sealed.ts'), 'sealed')
await chmod(locked, 0o000)
locks.push(locked)
const files = search(root)
const pending = files.list('README', new AbortController().signal)
files.dispose()
await expect(pending).rejects.toThrow('file search index disposed')
// The in-flight traversal still settles; its entries must reach no caller.
await vi.waitFor(async () => {
expect(await files.list('README', new AbortController().signal)).toEqual([])
})
const signal = new AbortController().signal
// The branch itself yields nothing, and the rest of the tree still does.
expect(await files.list('sealed', signal)).toEqual([])
expect(await files.list('README', signal)).toEqual([{ path: 'README.md', kind: 'file' }])
// The directory is still offered: only reading through it fails.
expect(await files.list('locked', signal)).toEqual([{ path: 'locked', kind: 'directory' }])
})
it('enforces the entry cap', async () => {
@@ -214,13 +226,23 @@ describe('WorkspaceFileSearch', () => {
it('never traverses an excluded build output, so generated twins cannot outrank sources', async () => {
const root = await workspace()
await mkdir(join(root, 'lib'), { recursive: true })
await writeFile(join(root, 'lib', 'terminal-view.js'), 'built')
await mkdir(join(root, 'dist'), { recursive: true })
await writeFile(join(root, 'dist', 'terminal-view.js'), 'built')
const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
expect(await files.list('terminal-view', new AbortController().signal)).toEqual([
{ path: 'src/terminal-view.ts', kind: 'file' },
])
expect(await files.list('lib/', new AbortController().signal)).toEqual([])
expect(await files.list('dist/', new AbortController().signal)).toEqual([])
})
it('still offers a `lib` tree, where several ecosystems keep their sources', async () => {
const root = await workspace()
await mkdir(join(root, 'lib'), { recursive: true })
await writeFile(join(root, 'lib', 'gem-entry.rb'), 'source')
const files = search(root, { excludedDirectories: [...DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES] })
expect(await files.list('gem-entry', new AbortController().signal)).toEqual([
{ path: 'lib/gem-entry.rb', kind: 'file' },
])
})
it('cancels individual callers, skips missing directories, and validates limits', async () => {
@@ -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/context/session-reference/README.md
README.md: f88e1aacd7f6c83e25f90165f49dae183d0a94d5
README.zh.md: c432abc8a551c3d9982c620d7c16d9d58e92611e
README.md: 804cfea6357d4e9fc202e6562e75d740c5702c24
README.zh.md: 4a249ea63cb7546603666990f67add54ebfe035a
+2 -2
View File
@@ -37,7 +37,7 @@ A message that cites other sessions is followed immediately by a `## Referenced
### Finding sessions to reference
`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the latest log-backed title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention.
`listCandidates(agent, query?, limit?)` lists sessions other than the agent's own, filters case-insensitively by id, working directory, or the projected title, and ranks same-directory sessions first. Each candidate carries its latest title as the mention label, falling back to the session id when the title is absent or unreadable, and reports whether its working directory is the requesting agent's so a host can surface a location only when it distinguishes the row. Browser consumers call the same discovery as `ctx.remote.sessionReferenceResolver.candidates`, which attaches each candidate's canonical mention.
### Configuration
@@ -121,7 +121,7 @@ The request and snapshot are consecutive append-only target messages and preserv
These limits define when cross-session references are a poor fit. They are current package constraints.
- **No body discovery** — candidate queries inspect titles but do not search message bodies.
- **Discovery cost tracks projection-cache coverage** — a session the projection cache has checkpointed costs no log read at all. Every other session is folded from its log once and remembered while that log stays cold, so a first query over a corpus the cache has not covered still reads those logs; a dedicated title index may replace that path without changing URI, snapshot, or persistence contracts. Without the cache composed, an unfiltered listing folds only its cwd-ranked head, so its tail cannot match a title substring.
- **Labels come from projections alone** — an attached session is labeled from its live projection cut, a cold one from its durable checkpoint, and a session neither answers for is labeled by its id and cannot be found by its title. Discovery never reads a log: folding one title costs a whole log, and this runs under every completion keystroke. A session persisted before the projection cache was composed regains its title the first time it is opened, which checkpoints it.
- **Trusted caller boundary** — the service assumes its host is authorized to read every session exposed by `ctx.sessionQuery`; it is not a model-facing search tool.
- **Text projection only** — non-text user and assistant blocks are not propagated across sessions.
- **No live link** — references are snapshots, not forks, resumes, subscriptions, or source-session mutations.
@@ -37,7 +37,7 @@ kind: "package-reference"
### 查找可引用的会话
`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或最新日志标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
`listCandidates(agent, query?, limit?)` 列出除 agent 自身外的会话,按 id、工作目录或投影标题做不区分大小写的过滤,并把同目录会话排在前面。每个候选以其最新标题作为 mention 标签;标题缺失或不可读时回退到会话 id,并报告其工作目录是否就是发起方 agent 的工作目录,宿主因此可以只在位置能区分该行时才显示它。浏览器消费方通过 `ctx.remote.sessionReferenceResolver.candidates` 调用同一发现能力,该方法会为每个候选附上规范 mention。
### 配置
@@ -121,7 +121,7 @@ kind: "package-reference"
这些限制说明跨会话引用何时不合适。它们是当前包约束。
- **不支持消息正文检索**:候选查询会检查标题,但不搜索消息主体。
- **发现成本取决于投影缓存的覆盖率**:投影缓存已建立 checkpoint 的会话完全不需要读日志。其余会话各自从日志折叠一次,并在该日志保持冷态期间被记住;因此对缓存尚未覆盖的语料,首次查询仍会读取那些日志——专用标题索引未来可以替换这条路径,而不改变 URI、快照或持久化约定。未组合缓存时,未经过滤的列表只折叠按 cwd 排序的头部,其尾部因此无法命中标题子串
- **标签只来自投影**:已挂载的会话由实时投影切面标注,冷会话由持久化 checkpoint 标注,两者都答不上来的会话用 id 作标签且无法按标题搜到。发现路径绝不读日志:折叠一个标题的代价是整份日志,而这段代码位于补全的每一次击键之下。早于投影缓存组合存在的会话,只要被打开一次(销毁时即写 checkpoint)就会恢复标题
- **受信任调用方边界**:该服务假设宿主有权读取 `ctx.sessionQuery` 公开的每个会话;它不是面向模型的搜索工具。
- **只投影文本**:不会在会话间传播非文本 user 与 assistant 块。
- **没有实时链接**:引用是快照,不是 fork、恢复、订阅或源会话变更。
+40 -155
View File
@@ -11,15 +11,13 @@ import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm'
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
// Type-only: the `title` projection key and the cache's Context merge, so
// discovery can label a cold session without reading its log.
import type { SessionId } from '@deepseek-ai/dsh-session'
// Type-only: the `title` projection key plus the live registry and durable
// cache Context merges — the two projection faces discovery labels from.
import type { ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-title'
import type {
SessionRecord, SessionSurfaceSnapshot, SessionTitleObservationResult,
} from '@deepseek-ai/dsh-session-query'
import type { SessionRecord, SessionSurfaceSnapshot } from '@deepseek-ai/dsh-session-query'
import {
DEFAULT_CANDIDATE_LIMIT,
DEFAULT_MAX_REFERENCE_BYTES,
@@ -78,25 +76,6 @@ interface RenderedSource {
stats: ReferenceRetentionStats
}
/** One listed session, its listing position (the stable rank tiebreak), and its resolved title. */
interface LabelledSession {
record: SessionRecord
index: number
label: string
/**
* No checkpoint answered for this session, so `label` is the id placeholder
* and only a log fold can improve it.
*/
unresolved?: boolean
}
/** One folded cold title, pinned to the log identity that produced it. */
interface FoldedTitle {
/** Creation facts of the folded header: a reused id with different ones is a different log. */
identity: string
title: string | undefined
}
/** Exact-read consumer that prepares immutable cross-session message context. */
export class SessionReferenceResolver extends TypertRemoteService {
static inject = ['sessionQuery']
@@ -107,12 +86,6 @@ export class SessionReferenceResolver extends TypertRemoteService {
})
private readonly config: Required<Config>
/**
* Cold-log title folds, kept for the process lifetime. A log that no
* session is attached to never grows, so one fold answers every later
* keystroke instead of re-reading the log per query.
*/
private readonly foldedTitles = new Map<SessionId, FoldedTitle>()
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'sessionReferenceResolver')
@@ -182,11 +155,9 @@ export class SessionReferenceResolver extends TypertRemoteService {
/**
* List reference candidates, ranked by working-directory affinity.
*
* A title comes from the projection cache when that cache holds a
* checkpoint for the session; otherwise it is folded from the session's log
* once and remembered for as long as the log stays cold. Without the cache
* composed, only the cwd-ranked head of an unfiltered listing is folded, so
* its tail cannot match a title substring.
* Discovery runs at keystroke rate, so a title only ever comes from a
* projection read: see {@link SessionReferenceResolver.projectedTitle} for
* which sessions can answer one and which fall back to their id.
* @param agent - target agent; self is excluded and its cwd drives ranking.
* @param query - optional case-insensitive session-id/cwd/title substring.
* @param limit - optional positive result cap.
@@ -208,8 +179,12 @@ export class SessionReferenceResolver extends TypertRemoteService {
const records = (await settleWithCancellation(this.ctx.sessionQuery.listSessions(signal), signal))
.filter(record => record.header.id !== agent.id)
.map((record, index) => ({ record, index }))
const labelled = await this.labelCandidates(records, needle, limit, targetCwd, signal)
const page = labelled.filter(({ record, label }) => {
const labelled = records.map(({ record, index }) => ({
record,
index,
label: this.projectedTitle(record) ?? record.header.id,
}))
return labelled.filter(({ record, label }) => {
if (needle === '') return true
return record.header.id.toLocaleLowerCase().includes(needle)
|| record.header.cwd?.toLocaleLowerCase().includes(needle) === true
@@ -217,7 +192,6 @@ export class SessionReferenceResolver extends TypertRemoteService {
}).sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|| a.index - b.index)
.slice(0, limit)
return (await this.foldUnresolved(page, signal))
.map(({ record, label }) => ({
sessionId: record.header.id,
label,
@@ -228,115 +202,34 @@ export class SessionReferenceResolver extends TypertRemoteService {
}
/**
* Resolve the title of every candidate the caller may filter.
* The title a session's projections can answer without reading its log.
*
* With the projection cache composed it is the discovery index: titles come
* from its synchronous checkpoint rows, so a query filters the whole corpus
* without reading one log. Sessions the cache never checkpointed stay
* `unresolved` for {@link SessionReferenceResolver.resolvePageLabels} to
* fold. Without the cache, folding a title costs a full log read per
* session, so only the cwd-ranked head is inspected and an unlabeled tail
* cannot match a title substring.
* @param records - non-self session records in listing order.
* @param needle - lowercased query; empty means no title can change the result set.
* @param limit - caller result cap, applied here to the fold path's head.
* @param targetCwd - requesting agent's working directory (the ranking key).
* @param signal - caller cancellation.
* @returns labeled records for the caller to filter, rank, and cap.
*/
private async labelCandidates(
records: readonly { record: SessionRecord; index: number }[],
needle: string,
limit: number,
targetCwd: string | undefined,
signal: AbortSignal | undefined,
): Promise<readonly LabelledSession[]> {
const cache = this.ctx.get('sessionProjectionCache')
if (cache !== undefined) {
const labelled = records.map(({ record, index }) => {
const checkpoint = cachedTitle(cache.cachedSnapshot(record.header, ['title']))
return {
record,
index,
label: checkpoint.title ?? record.header.id,
...checkpoint.checkpointed ? {} : { unresolved: true },
}
})
// A filter reads every label, so a query cannot defer the sessions the
// cache never checkpointed to the page: fold them now. An empty query
// filters nothing, so its unresolved tail waits for the capped page.
return needle === '' ? labelled : this.foldUnresolved(labelled, signal)
}
const inspected = needle === ''
? [...records]
.sort((a, b) => candidateRank(a.record.header.cwd, targetCwd) - candidateRank(b.record.header.cwd, targetCwd)
|| a.index - b.index)
.slice(0, limit)
: records
const observations = await settleWithCancellation(
this.ctx.sessionQuery.readTitleSnapshots(inspected.map(({ record }) => record.header.id), signal),
signal,
)
return inspected.map(({ record, index }, observationIndex) => {
const observation = observations[observationIndex] as SessionTitleObservationResult
return {
record,
index,
label: observation.status === 'fulfilled'
? observation.value.title?.title ?? record.header.id
: record.header.id,
}
})
}
/**
* Apply every title the projection cache could not answer.
* Attachment is decided by the store at read time, not by the listing:
* a session that attached in between would otherwise be answered from a
* checkpoint its live log has already moved past.
*
* A session persisted before the cache existed, seeded straight to disk, or
* whose record was cleared carries a title only in its log, and reading one
* costs a whole log. Memoized folds carry the cost once per cold log rather
* than once per keystroke; a session that is attached again is never
* memoized, because its log is still being appended to.
* @param entries - labeled rows, some still carrying the id placeholder.
* @param signal - caller cancellation.
* @returns the same rows with every foldable title applied.
* An attached session answers from its live registry cut, which advances
* with every committed event, so a rename or a just-generated title is
* visible immediately; its events are already in memory, so the lazy fold
* costs no I/O. A cold session answers from the durable checkpoint the
* projection cache wrote when it went cold.
*
* Nothing else is attempted. Folding a title from a log costs the whole
* log, and this call sits under every keystroke of `@` completion. A
* session that no projection can answer for — one persisted before the
* cache was composed, or seeded straight to disk — is labeled by its id
* and cannot be found by its title until it is opened once, which
* checkpoints it.
* @param record - the listed session, live or cold.
* @returns the projected title, or undefined when no projection holds one.
*/
private async foldUnresolved(
entries: readonly LabelledSession[],
signal: AbortSignal | undefined,
): Promise<readonly LabelledSession[]> {
const live = this.ctx.get('sessions')
const pending: SessionHeader[] = []
const resolved = new Map<SessionId, string>()
for (const { record, unresolved } of entries) {
if (unresolved !== true) continue
const memo = record.live ? undefined : this.foldedTitles.get(record.header.id)
if (memo !== undefined && memo.identity === foldIdentity(record.header)) {
if (memo.title !== undefined) resolved.set(record.header.id, memo.title)
continue
}
pending.push(record.header)
private projectedTitle(record: SessionRecord): string | undefined {
const attached = this.ctx.get('sessions')?.get(record.header.id)
const projections = this.ctx.get('sessionProjections')
if (attached !== undefined && projections !== undefined) {
return titleOf(projections.snapshot(attached, ['title']))
}
if (pending.length > 0) {
const observations = await settleWithCancellation(
this.ctx.sessionQuery.readTitleSnapshots(pending.map(header => header.id), signal),
signal,
)
pending.forEach((header, at) => {
const observation = observations[at] as SessionTitleObservationResult
if (observation.status !== 'fulfilled') return
const title = observation.value.title?.title
if (title !== undefined) resolved.set(header.id, title)
// An attached session's log is still growing, so its fold is a cut,
// not a fact to keep.
if (live?.get(header.id) === undefined) {
this.foldedTitles.set(header.id, { identity: foldIdentity(header), title })
}
})
}
return entries.map(entry => (entry.unresolved === true
? { ...entry, label: resolved.get(entry.record.header.id) ?? entry.label }
: entry))
return titleOf(this.ctx.get('sessionProjectionCache')?.cachedSnapshot(record.header, ['title']))
}
/**
@@ -470,18 +363,10 @@ function renderPrompt(data: readonly ReferencedSessionData[]): string {
return `${PROMPT_PREFIX}${stringifyTagSafeJson(data)}${PROMPT_SUFFIX}`
}
/** The creation facts that make a header's log the same log the memo folded. */
function foldIdentity(header: SessionHeader): string {
return `${String(header.createdAt)}:${header.cwd ?? ''}`
}
/** Read one cached title row, separating "no checkpoint" from "checkpointed, still untitled". */
function cachedTitle(
snapshot: ProjectionSnapshot | undefined,
): { checkpointed: boolean; title?: string } {
/** The title in one projection snapshot; undefined when the unit is absent or still untitled. */
function titleOf(snapshot: ProjectionSnapshot | undefined): string | undefined {
const title = snapshot?.values.title
if (title === undefined) return { checkpointed: false }
return title === null ? { checkpointed: true } : { checkpointed: true, title }
return title === undefined || title === null ? undefined : title
}
function candidateRank(candidateCwd: string | undefined, targetCwd: string | undefined): number {
@@ -4,7 +4,9 @@ import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compaction'
import { createUserMessage, ToolCallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import SessionReferenceResolver, {
decodeSessionReferenceUri,
encodeSessionReferenceUri,
@@ -35,6 +37,11 @@ class TestSessionQueryEngine extends SessionQueryEngine {
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
// The live registry and the title unit it hosts: discovery labels an
// attached session from its projection cut, never from its log.
await ctx.plugin(SessionProjectionRegistry)
// Shipped base values: this suite only needs the unit the service registers.
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 })
await ctx.plugin(TestSessionQueryEngine)
await ctx.plugin(SessionReferenceResolver, config)
return ctx
@@ -296,141 +303,75 @@ describe('session reference discovery and preparation', () => {
listSessions.mockRestore()
})
it('labels and filters the whole corpus from checkpoints, reading no log', async () => {
it('reads an attached session\'s current title, ahead of any checkpoint', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
for (const id of ['alpha', 'beta']) {
const created = ctx.sessions.create(SessionId(id), { meta: { cwd: '/same' } })
created.append('session/title', { title: `${id} title`, messageSeqs: [], source: { kind: 'fallback' } })
}
withProjectionCache(ctx, { alpha: 'Alpha checkpoint', beta: 'Beta checkpoint' })
const live = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/same' } })
live.append('session/title', { title: 'Old title', messageSeqs: [], source: { kind: 'fallback' } })
// The durable checkpoint is write-behind, so it still holds the old value.
withProjectionCache(ctx, { live: 'Old title' })
live.append('session/title', { title: 'Renamed mid turn', messageSeqs: [], source: { kind: 'user' } })
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'alpha check'))
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'renamed'))
.resolves.toEqual([
{ sessionId: SessionId('alpha'), label: 'Alpha checkpoint', cwd: '/same', sameWorkspace: true, createdAt: expect.any(Number) as number },
{ sessionId: live.id, label: 'Renamed mid turn', cwd: '/same', sameWorkspace: true, createdAt: live.header.createdAt },
])
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'old title')).resolves.toEqual([])
expect(readTitles).not.toHaveBeenCalled()
readTitles.mockRestore()
})
it('folds a title the cache never checkpointed, for the shown page alone', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } })
seeded.append('session/title', { title: 'Seeded title', messageSeqs: [], source: { kind: 'fallback' } })
const untitled = ctx.sessions.create(SessionId('untitled'), { meta: { cwd: '/same' } })
// `untitled` is checkpointed with no title yet: nothing a log fold could add.
withProjectionCache(ctx, { untitled: null })
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
{ sessionId: seeded.id, label: 'Seeded title', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt },
{ sessionId: untitled.id, label: untitled.id, cwd: '/same', sameWorkspace: true, createdAt: untitled.header.createdAt },
])
// Only the uncheckpointed session reached a log.
expect(readTitles).toHaveBeenCalledTimes(1)
expect(readTitles.mock.calls[0]?.[0]).toEqual([seeded.id])
readTitles.mockRestore()
})
it('folds the uncheckpointed tail so a query still filters on its titles', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const seeded = ctx.sessions.create(SessionId('seeded'), { meta: { cwd: '/same' } })
seeded.append('session/title', { title: 'Research notes', messageSeqs: [], source: { kind: 'fallback' } })
withProjectionCache(ctx, {})
// The title lives only in the log, and the filter reads labels — so a
// deferred fold would make this session unfindable by its own title.
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'research'))
.resolves.toEqual([
{ sessionId: seeded.id, label: 'Research notes', cwd: '/same', sameWorkspace: true, createdAt: seeded.header.createdAt },
])
})
it('folds a cold log once and answers every later query from that fold', async () => {
it('labels a cold session from its checkpoint and reads no log', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const cold = { id: SessionId('cold'), createdAt: 10, cwd: '/same' }
withProjectionCache(ctx, {})
withProjectionCache(ctx, { cold: 'Cold checkpoint' })
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
{ header: { ...target.header }, live: true, persisted: false },
{ header: cold, live: false, persisted: true },
] as never)
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{
sessionId: cold.id,
status: 'fulfilled',
value: { session: cold, title: { title: 'Cold title' } },
}] as never)
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
const expected = [{ sessionId: cold.id, label: 'Cold title', cwd: '/same', sameWorkspace: true, createdAt: 10 }]
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold')).resolves.toEqual(expected)
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'cold t')).resolves.toEqual(expected)
// A cold log never grows, so the second keystroke reads nothing.
expect(readTitles).toHaveBeenCalledTimes(1)
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'checkpoint'))
.resolves.toEqual([
{ sessionId: cold.id, label: 'Cold checkpoint', cwd: '/same', sameWorkspace: true, createdAt: 10 },
])
expect(readTitles).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('remembers that a cold log has no title, and stops reading it', async () => {
it('labels a session no projection answers for by its id, still without a log read', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const seeded = { id: SessionId('seeded'), createdAt: 10, cwd: '/same' }
// Persisted before the cache was composed: the title lives only in its log.
withProjectionCache(ctx, {})
vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([
{ header: { id: SessionId('bare'), createdAt: 10 }, live: false, persisted: true },
{ header: seeded, live: false, persisted: true },
] as never)
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValue([{
sessionId: SessionId('bare'),
status: 'fulfilled',
value: { session: {} },
}] as never)
const expected = [{ sessionId: SessionId('bare'), label: 'bare', sameWorkspace: false, createdAt: 10 }]
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bare')).resolves.toEqual(expected)
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'bar')).resolves.toEqual(expected)
expect(readTitles).toHaveBeenCalledTimes(1)
vi.restoreAllMocks()
})
it('refolds a cold id whose log was replaced under it', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
withProjectionCache(ctx, {})
let createdAt = 10
vi.spyOn(ctx.sessionQuery, 'listSessions').mockImplementation(() => Promise.resolve([
{ header: { id: SessionId('cold'), createdAt, cwd: '/same' }, live: false, persisted: true },
] as never))
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
.mockImplementation(() => Promise.resolve([{
sessionId: SessionId('cold'),
status: 'fulfilled',
value: { session: {}, title: { title: `Title at ${String(createdAt)}` } },
}] as never))
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title'))
.resolves.toMatchObject([{ label: 'Title at 10' }])
createdAt = 20
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'title'))
.resolves.toMatchObject([{ label: 'Title at 20' }])
expect(readTitles).toHaveBeenCalledTimes(2)
vi.restoreAllMocks()
})
it('leaves the id placeholder when the page fold cannot read the log', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const broken = ctx.sessions.create(SessionId('broken'), { meta: { cwd: '/same' } })
withProjectionCache(ctx, {})
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{
sessionId: broken.id,
status: 'rejected',
reason: new Error('broken title log'),
}])
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
{ sessionId: broken.id, label: broken.id, cwd: '/same', sameWorkspace: true, createdAt: broken.header.createdAt },
{ sessionId: seeded.id, label: seeded.id, cwd: '/same', sameWorkspace: true, createdAt: 10 },
])
// Its own title cannot find it, and discovery still never opens the log.
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'anything')).resolves.toEqual([])
expect(readTitles).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('labels every session by id when no projection face is composed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(TestSessionQueryEngine)
await ctx.plugin(SessionReferenceResolver)
const target = ctx.sessions.create(SessionId('target'), { meta: { cwd: '/same' } })
const other = ctx.sessions.create(SessionId('other'), { meta: { cwd: '/same' } })
other.append('session/title', { title: 'Unreadable', messageSeqs: [], source: { kind: 'fallback' } })
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target))).resolves.toEqual([
{ sessionId: other.id, label: other.id, cwd: '/same', sameWorkspace: true, createdAt: other.header.createdAt },
])
readTitles.mockRestore()
})
it('serves the Remote face with the configured limit and canonical mentions', async () => {
@@ -528,38 +469,15 @@ describe('session reference discovery and preparation', () => {
)).rejects.toThrow(/invalid session reference URI/)
})
it('keeps metadata matches when one title observation fails and cancels a stalled title batch', async () => {
it('still matches an unlabeled session on its own metadata', async () => {
const ctx = await harness()
const target = ctx.sessions.create(SessionId('target'))
// No cwd, no title event: nothing but the id identifies it.
const source = ctx.sessions.create(SessionId('source'))
const readTitles = vi.spyOn(ctx.sessionQuery, 'readTitleSnapshots')
readTitles.mockResolvedValueOnce([{
sessionId: source.id,
status: 'rejected',
reason: new Error('broken title log'),
}])
await expect(ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source')).resolves.toEqual([
{ sessionId: source.id, label: source.id, sameWorkspace: false, createdAt: source.header.createdAt },
])
let releaseTitles: (() => void) | undefined
let titleSignal: AbortSignal | undefined
readTitles.mockImplementationOnce(async (_ids, signal) => {
titleSignal = signal
await new Promise<void>((resolve) => { releaseTitles = resolve })
return []
})
const controller = new AbortController()
const pending = ctx.sessionReferenceResolver.listCandidates(fakeAgent(target), 'source', undefined, controller.signal)
await vi.waitFor(() => { expect(releaseTitles).toBeTypeOf('function') })
expect(titleSignal).toBe(controller.signal)
const cancelledTitles = expect(pending).rejects.toThrow(expectCode('SESSION_REFERENCE_CANCELLED'))
controller.abort('autocomplete superseded')
await cancelledTitles
releaseTitles?.()
await Promise.resolve()
readTitles.mockRestore()
})
it('projects only the current user/assistant surface and records snapshot metadata', async () => {
@@ -1708,7 +1708,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'async listCandidates( agent: Agent, query: string = \'\', limit: number = this.config.candidateLimit, signal?: AbortSignal, ): Promise<SessionReferenceCandidate[]>',
description: 'List reference candidates, ranked by working-directory affinity.\n\nA title comes from the projection cache when that cache holds a checkpoint for the session; otherwise it is folded from the session\'s log once and remembered for as long as the log stays cold. Without the cache composed, only the cwd-ranked head of an unfiltered listing is folded, so its tail cannot match a title substring.',
description: 'List reference candidates, ranked by working-directory affinity.\n\nDiscovery runs at keystroke rate, so a title only ever comes from a projection read: see SessionReferenceResolver.projectedTitle for which sessions can answer one and which fall back to their id.',
parameters: [{ name: 'agent', description: 'target agent; self is excluded and its cwd drives ranking.' }, { name: 'query', description: 'optional case-insensitive session-id/cwd/title substring.' }, { name: 'limit', description: 'optional positive result cap.' }, { name: 'signal', description: 'optional cancellation boundary for host autocomplete teardown.' }],
returns: 'candidates labeled by latest title or, when absent, session id.',
},