fix(web): hide verified cold blank sessions
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||||
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
|
# after editing either side, bring the other along and re-record with:
|
||||||
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md
|
||||||
|
2026-08-13-bounded-cold-blank-verification.md: 330f5acbd520487732bc72fe75f09496aaa0c028
|
||||||
|
2026-08-13-bounded-cold-blank-verification.zh.md: 15c0d28be8c15f7076ac90a50bb023cc42fbbdd6
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Agent Note: Bound cold blank-session verification
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-08-13-bounded-cold-blank-verification.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The Web session tree hides blank Sessions and reuses the selected blank entry as New Session. Attached Sessions can derive blankness from their in-memory event log, but `session.list` normally avoids loading every cold log. Treating every materialized cold Session as non-blank exposes empty Sessions left by older versions. Treating a projection-cache `blank: true` as current can instead hide a real conversation after the log advances and the fail-soft cache remains stale.
|
||||||
|
|
||||||
|
The same cold list used the JSONL artifact mtime for `updatedAt`. Opening a Session appends `session/end-seed`, so a pickup with no human prompt refreshed mtime and promoted that Session above recently used conversations.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`dsh-host-apiproxy` registers `sessionListMetadata`, a projection containing `blank` and `lastPromptAt`. The attached summary folds the same functions directly over the live log. `blank` changes only from true to false on `turn/start`; `lastPromptAt` changes only on a `user/message` whose source kind is `user`.
|
||||||
|
|
||||||
|
A cold summary trusts cached `blank: false`, because a checkpoint prefix containing `turn/start` remains non-blank. Cached `blank: true` and a cache miss do not prove the current log is blank. When persistence exposes a physical artifact through `locate()` and its size is at most `coldBlankProbeMaxBytes` (default 1 KiB per Session), the gateway calls `readFrom(id, 0)` and verifies whether the stored prefix contains `turn/start`. Files above the bound, backends without a location, vanished artifacts, and failed reads all produce `blank: false`, keeping the Session visible.
|
||||||
|
|
||||||
|
`updatedAt` is the later of `createdAt` and `lastPromptAt`. A cold cache miss or stale checkpoint therefore orders the Session too old rather than promoting it from an unrelated file write. The bounded blank read does not replace missing recency metadata.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**Trust cached `blank: true`.** Rejected because the projection cache deliberately permits a persisted log to advance beyond its checkpoint. A crash or fail-soft write failure after the first `turn/start` would hide a real conversation and could make the client reuse it as New Session.
|
||||||
|
|
||||||
|
**Read every cold log.** Rejected because list latency and I/O would scale with total stored conversation bytes. The physical-size bound targets the small historical artifacts that can be checked cheaply and degrades larger unknowns toward visibility.
|
||||||
|
|
||||||
|
**Store blankness and recency in an authoritative persistence index.** Deferred because JSONL has an immutable first line and would require a second durable artifact with ordered updates, while SQLite would require a schema field. The broader exact-index design remains in the [last-activity proposal](../../proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
||||||
|
|
||||||
|
**Continue ordering JSONL by mtime.** Rejected because mtime records every artifact write, including pickup boundaries, rather than the latest human prompt. Its error direction promotes untouched Sessions to the front.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Existing small blank JSONL artifacts are hidden without depending on projection-cache availability, and a stale cache cannot hide a stored `turn/start`. A cold list may read each artifact whose physical size is within the configured bound when its cache does not already prove non-blank. The default bound applies to compressed bytes for the shipped Zstandard JSONL backend.
|
||||||
|
|
||||||
|
Blank artifacts above the bound and blank Sessions on location-less backends remain visible. Missing or delayed recency cache entries fall back to `createdAt`. These are conservative degradations: the UI may show an extra empty row or order a Session too low, but it does not hide a conversation or promote one because it was merely opened.
|
||||||
|
|
||||||
|
The gateway-owned projection is an effect of the gateway fiber; unloading the gateway removes the key. Unit coverage pins exact-threshold probing, stale-true rejection, monotonic false reuse, fallback direction, human-prompt recency, and fiber disposal. A keyless Web snapshot boots the shipped compressed JSONL composition, seeds a small cold blank artifact without a cache row, and verifies that the sidebar omits it.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Agent Note: 有界验证冷空白会话
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-08-13-bounded-cold-blank-verification.md) | 中文
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Web 会话树会隐藏空白 Session,并把当前选中的空白项复用为 New Session。已附加 Session 可以从内存事件日志派生空白状态,但 `session.list` 通常不会加载每一份冷日志。把所有已物化的冷 Session 都视为非空,会暴露旧版本留下的空 Session;反过来,把 projection cache 中的 `blank: true` 当成当前事实,则可能在日志已经前进而 fail-soft cache 仍然陈旧时隐藏真实对话。
|
||||||
|
|
||||||
|
同一份冷列表还曾用 JSONL 工件的 mtime 作为 `updatedAt`。打开 Session 会追加 `session/end-seed`,因此即使没有真人 prompt,单纯拾起也会刷新 mtime,并把该 Session 提升到最近使用的对话之前。
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`dsh-host-apiproxy` 注册 `sessionListMetadata` 投影,其中包含 `blank` 与 `lastPromptAt`。已附加摘要直接用同一组函数折叠实时日志。`blank` 只在 `turn/start` 时从 true 单调变为 false;`lastPromptAt` 只在来源 kind 为 `user` 的 `user/message` 上更新。
|
||||||
|
|
||||||
|
冷摘要信任缓存的 `blank: false`,因为已包含 `turn/start` 的 checkpoint 前缀会始终保持非空。缓存的 `blank: true` 和 cache miss 都无法证明当前日志为空。当 persistence 通过 `locate()` 暴露物理工件,且其大小不超过 `coldBlankProbeMaxBytes`(默认每个 Session 1 KiB)时,网关调用 `readFrom(id, 0)`,验证已存前缀是否含有 `turn/start`。超过上限的文件、不提供位置的后端、已消失的工件和读取失败都产生 `blank: false`,让 Session 保持可见。
|
||||||
|
|
||||||
|
`updatedAt` 取 `createdAt` 与 `lastPromptAt` 中较晚者。因此冷 cache miss 或陈旧 checkpoint 只会让 Session 排得偏旧,而不会因无关的文件写入被提升。有界 blank 读取不用于补齐缺失的最近时间元数据。
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
**信任缓存的 `blank: true`。** 拒绝,因为 projection cache 有意允许持久日志前进到 checkpoint 之后。首个 `turn/start` 之后若发生崩溃或 fail-soft 写入失败,真实对话就会被隐藏,客户端还可能把它复用为 New Session。
|
||||||
|
|
||||||
|
**读取每一份冷日志。** 拒绝,因为列表延迟与 I/O 会随所有已存对话的总字节数增长。物理大小上限只针对能够低成本核验的小型历史工件,更大的未知项则向保持可见降级。
|
||||||
|
|
||||||
|
**把空白状态与最近时间存入权威 persistence index。** 暂缓,因为 JSONL 的首行不可变,需要增加带有顺序写入要求的第二份持久工件;SQLite 则需要 schema 字段。更广泛的精确索引设计仍由[最后活动提案](../../proposed/architecture/2026-07-29-durable-last-activity-index.md)负责。
|
||||||
|
|
||||||
|
**继续按 mtime 排序 JSONL。** 拒绝,因为 mtime 记录包括拾起边界在内的每一次工件写入,而非最近真人 prompt;其错误方向会把未经操作的 Session 提升到列表开头。
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
既有的小型空白 JSONL 工件无需依赖 projection cache 是否存在即可被隐藏,陈旧 cache 也无法隐藏已存的 `turn/start`。对于 cache 尚不能证明非空,且物理大小在配置上限内的每个 Session,冷列表可能读取其工件。对默认交付的 Zstandard JSONL 后端,该上限作用于压缩后的字节数。
|
||||||
|
|
||||||
|
超过上限的空白工件,以及来自不提供位置的后端的空白 Session 会保持可见。缺失或延迟的最近时间 cache 会回退到 `createdAt`。这些都是保守降级:UI 可能多显示一条空记录,或把 Session 排得偏低,但不会隐藏真实对话,也不会因为单纯打开而把会话提升到前面。
|
||||||
|
|
||||||
|
网关自有投影是网关 fiber 的 effect;卸载网关会移除该 key。单元覆盖固定了临界大小探测、拒绝陈旧 true、复用单调 false、回退方向、真人 prompt 最近时间和 fiber 销毁。无密钥 Web snapshot 会启动发行版的压缩 JSONL 组合,在没有 cache row 的情况下播种一份小型冷空白工件,并验证侧栏不展示它。
|
||||||
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
|
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md
|
||||||
2026-07-29-durable-last-activity-index.md: 0e441f54a719b29a1a450c133e08cdf7d2c82e9e
|
2026-07-29-durable-last-activity-index.md: b530508877adf3a66b158ff659441c4179878d00
|
||||||
2026-07-29-durable-last-activity-index.zh.md: ebc2e2167d7743eafc5a4600fe9a0f687349e1a3
|
2026-07-29-durable-last-activity-index.zh.md: 4c099bc835c4708fda09811fafd6dabb65dbb575
|
||||||
@@ -6,17 +6,17 @@ English | [中文](2026-07-29-durable-last-activity-index.zh.md)
|
|||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|
||||||
A cold (persisted, unattached) session has no stored answer to "when was this last worked in". `dsh-host-apiproxy`'s `summarizeCold()` therefore approximates it with the log file's mtime where one exists — `locate()` resolves a per-session artifact for JSONL and `undefined` for SQLite, whose cold sessions fall back to `createdAt` — and the web client sorts its session tree by the resulting `updatedAt`. The two backends are wrong in opposite directions: JSONL reads too new, SQLite too old.
|
A cold (persisted, unattached) session has no authoritative stored answer to "when did the user last prompt here". `dsh-host-apiproxy` serves `updatedAt` from the optional projection cache's `lastPromptAt`, falling back to `createdAt`, and the Web client sorts its Session tree by that value. The cache is fail-soft and checkpointed asynchronously, so a missing or delayed row makes a recently prompted Session sort too old.
|
||||||
|
|
||||||
mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including writes that are not activity — a truncate-repair of a torn tail, the synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) a seeded session appends. (A `flush` with nothing pending is not among them: the coordinator returns without reaching the backend.) The visible consequence is stable and wrong in one direction: a session touched without being worked in promotes itself above sessions the user actually worked in afterwards, and each touch re-promotes it. `dsh-host-apiproxy` keeps `session.history` inspection-only, but any Agent-bound ordinary-session control resumes through `agentFor()` and is enough to promote the cold artifact.
|
The gateway previously used JSONL artifact mtime when available. mtime answers a different question: when the artifact was last written. Every durable write refreshes it, including a truncate-repair of a torn tail, synthetic closers that balance an interrupted turn, and the [`session/end-seed` boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) appended during pickup. That approximation promoted a Session merely because it was opened. The [bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) removed mtime ordering and accepted the cache's conservative "too old" failure direction as an interim tradeoff.
|
||||||
|
|
||||||
The attached projection has a real fix — `lastActivityTime()` skips boundaries — but it needs the event log, and the cold path deliberately does not read one. Reading the log to compute `updatedAt` would defeat the header-only listing that keeps `list()` scaling with session count rather than log size.
|
An attached summary can fold the live event log and select the latest human-authored `user/message`, but the cold path deliberately does not read large logs. Reading every log to compute `updatedAt` would make `list()` scale with total conversation bytes rather than Session count. The 1 KiB cold read introduced for blank verification does not solve recency: it is conditional, targets only small artifacts, and does not make large-log ordering exact.
|
||||||
|
|
||||||
The [boundary change](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) raised the frequency of this defect, because a pickup now writes where nothing was written before; `dsh-host-apiproxy`'s README records it under Known Limitations. It did not introduce the approximation, and removing the approximation is a durable-format decision, which is why it is scoped here rather than there.
|
Making cold ordering exact remains a durable-format decision, which is why it is scoped here rather than in the gateway workaround.
|
||||||
|
|
||||||
## Proposal
|
## Proposal
|
||||||
|
|
||||||
Store last-activity time where a listing already reads — the session index — so `summarizeCold()` can serve it without opening the log. The coordinator computes the value, because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, and keeps one definition of "activity" shared with the in-log `lastActivityTime()`.
|
Store the latest human-prompt time where a listing already reads — the Session index — so `summarizeCold()` can serve it without opening the log or depending on a cache checkpoint. The coordinator computes the value because it sees every append and already owns per-id state; backends persist it. That makes it a new `PersistenceBackend` contract element rather than backend-local bookkeeping, with the same event predicate as the attached projection: `user/message` whose `source.kind` is `user`.
|
||||||
|
|
||||||
The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them:
|
The two shipped backends have opposite constraints, and the proposal is deliberately asymmetric about them:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ The two shipped backends have opposite constraints, and the proposal is delibera
|
|||||||
|
|
||||||
Three questions must be answered before implementation, and none of them is settled here:
|
Three questions must be answered before implementation, and none of them is settled here:
|
||||||
|
|
||||||
**Which events count as activity?** `lastActivityTime()` answers this for the log by excluding `session/end-seed`. A stored field encodes the rule at write time, where the writer sees one batch rather than the whole log. The two must not drift, or the attached and cold surfaces will disagree about the same session.
|
**How is the shared predicate owned?** A stored field encodes the rule at write time, where the writer sees one batch, while the attached summary folds a whole log. Both must use one exported event predicate or reducer so new message-source variants cannot make attached and cold ordering disagree.
|
||||||
|
|
||||||
**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
|
**How do pre-field logs behave?** Existing artifacts have no value. Falling back to mtime keeps them at today's accuracy; falling back to `createdAt` is honest but reorders every existing session in the picker and the tree.
|
||||||
|
|
||||||
@@ -39,28 +39,29 @@ Three questions must be answered before implementation, and none of them is sett
|
|||||||
|
|
||||||
**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
|
**Write the boundary only when repair occurred.** Would reduce the frequency, and the [boundary note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) already rejected it: the predicate must hold for an orderly restart too. Trading a correctness invariant for timestamp accuracy is the wrong direction.
|
||||||
|
|
||||||
**Derive activity from a projection cache.** `session-projection-cache` already folds tails past a watermark, so a last-activity unit would ride existing machinery. Rejected as the primary shape because the cache is an optional composition entry; a listing served only when a cache plugin is mounted makes ordering depend on composition.
|
**Derive activity from a projection cache.** This is the current interim implementation. `session-projection-cache` folds tails past a watermark without changing the persistence format, but it is optional and fail-soft. Its absence or checkpoint delay makes ordering depend on cache availability and freshness, so it cannot provide the authoritative value proposed here.
|
||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- `SessionSummary.updatedAt` for a cold session equals the same value the attached projection reports for that session, verified by resuming, quitting without a turn, and asserting the order is unchanged across both paths.
|
- `SessionSummary.updatedAt` for a cold session equals the same value the attached projection reports for that session, verified by resuming, quitting without a turn, and asserting the order is unchanged across both paths.
|
||||||
- A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone.
|
- A resumed-then-abandoned session does not sort above a session worked in afterwards, in the web session tree and the TUI resume picker, pinned by an assembled snapshot rather than unit tests alone.
|
||||||
- The activity rule has one definition: a test proves the stored field and `lastActivityTime()` agree over a log containing boundaries, closers, and a plain turn.
|
- The prompt-time rule has one definition: a test proves the stored field and attached fold agree over a log containing human prompts, injected user messages, boundaries, and closers.
|
||||||
- Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted.
|
- Pre-field artifacts load and list without error under the chosen fallback, with the fallback's ordering consequence asserted.
|
||||||
- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance.
|
- SQLite's `SCHEMA_VERSION` bump rejects the old on-disk version per the repo's no-migration stance.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
**Two definitions of activity drift.** The stored field is computed per batch, the projection over a whole log. A new event type classified one way at write time and the other at read time yields a session whose cold and attached orderings disagree — a bug that only appears after a restart, which is where it is hardest to notice.
|
**Two definitions of prompt time drift.** The stored field is computed per batch, the projection over a whole log. A new message source classified one way at write time and the other at read time yields a Session whose cold and attached orderings disagree — a bug that only appears after restart.
|
||||||
|
|
||||||
**A JSONL sidecar can disagree with its log.** A crash between the log append and the sidecar write leaves a stale value with no torn-tail marker to repair it. Every consumer would need to treat the sidecar as a hint, which is close to what mtime already is.
|
**A JSONL sidecar can disagree with its log.** A crash between the log append and the sidecar write leaves a stale value with no torn-tail marker to repair it. Every consumer would need to treat the sidecar as a hint, which is close to what mtime already is.
|
||||||
|
|
||||||
**The fallback reorders existing sessions.** Whichever fallback is chosen, users with existing logs see their picker and tree reorder once on upgrade. `createdAt` makes that reordering large.
|
**The fallback reorders existing sessions.** Whichever fallback is chosen, users with existing logs see their picker and tree reorder once on upgrade. `createdAt` makes that reordering large.
|
||||||
|
|
||||||
**Cost may exceed the defect.** The defect is a misordering of abandoned sessions. If the honest answer for JSONL is "keep the approximation", this note's outcome may be documenting that decision rather than implementing a field — and that is an acceptable outcome.
|
**Cost may exceed the defect.** The remaining defect is conservative misordering when projection metadata is missing or delayed. If the honest answer for JSONL is "keep the cache fallback", this note's outcome may be documenting that decision rather than implementing a field.
|
||||||
|
|
||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-activity writes mtime counts; `dsh-session` owns `lastActivityTime()`, the in-log projection a stored field must agree with.
|
- [Bounded cold blank verification](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) — removes mtime ordering, defines the interim projection-cache fallback, and limits direct cold reads to blankness checks.
|
||||||
|
- [The end-seed log boundary](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md) — one of the non-prompt writes that made mtime unsuitable.
|
||||||
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
|
- [Session persistence](../../implemented/architecture/2026-06-14-session-persistence.md) — the append-only and never-rewrite invariants that rule out a mutable JSONL header field.
|
||||||
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
|
- [Shared persistence write coordinator](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) — the append path a stored field would hook into.
|
||||||
@@ -6,17 +6,17 @@ Status: proposed
|
|||||||
|
|
||||||
## 问题
|
## 问题
|
||||||
|
|
||||||
一个冷会话(已持久化、未附加)对「上次是什么时候在这里面工作过」没有任何已存储的答案。因此 `dsh-host-apiproxy` 的 `summarizeCold()` 在存在日志文件时用它的 mtime 来近似它——`locate()` 为 JSONL 解析出一个逐会话产物,为 SQLite 解析出 `undefined`,而 SQLite 的冷会话会回退到 `createdAt`——而 web 客户端就按由此得到的 `updatedAt` 为自己的会话树排序。这两个后端错的方向正好相反:JSONL 读出来偏新,SQLite 偏旧。
|
一个冷会话(已持久化、未附加)对「用户上次是什么时候在这里发出 prompt」没有权威的已存储答案。`dsh-host-apiproxy` 从可选 projection cache 的 `lastPromptAt` 提供 `updatedAt`,缺失时回退到 `createdAt`,Web 客户端按该值为 Session 树排序。cache 采用 fail-soft 并异步写入 checkpoint,因此缺失或延迟的记录会让最近收到 prompt 的 Session 排得过旧。
|
||||||
|
|
||||||
mtime 回答的是另一个问题:这份产物上次是什么时候被写入的。每一次持久写入都会刷新它,包括那些并不是活动的写入:一次对撕裂尾部的截断修复、用来平衡被中断的轮次的那些合成 closer,以及带种子的会话会追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。(没有待处理内容的 `flush` 不在其中:协调器在到达后端之前就返回了。)用户可见的后果是稳定的,而且只朝一个方向错:一个被触碰过却没有在里面工作过的会话,会把自己排到用户此后真正工作过的那些会话之前,而且每次触碰都会重新把它排上去一次。`dsh-host-apiproxy` 让 `session.history` 保持只执行检查,但任何绑定到 Agent 的普通会话控件都会通过 `agentFor()` 恢复会话,足以把冷态产物排到前面。
|
网关以前会在可用时采用 JSONL 产物的 mtime。mtime 回答的是另一件事:这份产物上次是什么时候被写入。每一次持久写入都会刷新它,包括对撕裂尾部的截断修复、平衡中断轮次的合成 closer,以及拾起时追加的 [`session/end-seed` 边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)。这套近似会让 Session 仅仅因为被打开就提升排序。[有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)移除了 mtime 排序,并把 cache 保守的「过旧」错误方向作为现阶段取舍。
|
||||||
|
|
||||||
已附加会话的那个投影有真正的修复办法(`lastActivityTime()` 会跳过边界),但它需要事件日志,而冷路径有意不去读日志。为计算 `updatedAt` 而读取日志,会让只读 header 的列举失去意义,而正是它让 `list()` 的开销随会话数量而非日志体量增长。
|
已附加摘要可以折叠实时事件日志并选择最新的真人 `user/message`,但冷路径有意不读取大日志。为计算 `updatedAt` 而读取每一份日志,会让 `list()` 的开销随对话总字节数而非 Session 数量增长。为空白验证引入的 1 KiB 冷读取并不能解决最近时间:它是条件式的,只针对小产物,也不能让大日志的排序精确。
|
||||||
|
|
||||||
[边界那次变更](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)提高了这个缺陷的出现频率,因为一次拾起如今会在此前完全无写入的路径上产生写入;`dsh-host-apiproxy` 的 README 已在 Known Limitations 中记录该项。它并没有引入这套近似做法,而移除这套近似是一项持久格式决策,因此它的范围划在本文,而不是那里。
|
让冷排序变得精确仍是一项持久格式决策,因此其范围留在本文,而不是网关 workaround 中。
|
||||||
|
|
||||||
## 提案
|
## 提案
|
||||||
|
|
||||||
把最后活动时间存到列举本就会读取的地方,也就是会话索引,这样 `summarizeCold()` 无需打开日志就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素,而不是各后端本地的账目,同时让「活动」只保留一个定义,与日志内的 `lastActivityTime()` 共用。
|
把最新真人 prompt 时间存到列举本就会读取的 Session 索引,这样 `summarizeCold()` 无需打开日志或依赖 cache checkpoint 就能给出答案。该值由协调器计算,因为它看得到每一次追加,而且本就拥有每 id 状态;由后端负责持久化。这样它就成为 `PersistenceBackend` 约定中新增的一个要素,而不是各后端本地账目,并与已附加投影使用同一个事件谓词:`source.kind` 为 `user` 的 `user/message`。
|
||||||
|
|
||||||
两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理:
|
两个已交付的后端受到的约束正好相反,本提案对它们有意采取不对称的处理:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
|
|||||||
|
|
||||||
实现之前必须回答三个问题,本文对它们都没有定论:
|
实现之前必须回答三个问题,本文对它们都没有定论:
|
||||||
|
|
||||||
**哪些事件算作活动?** 对日志而言,`lastActivityTime()` 通过排除 `session/end-seed` 回答了这个问题。一个已存储字段是在写入时编码这条规则的,而写入方在那里只看到一个批次,不是整份日志。两者不得发生漂移,否则已附加表层与冷表层会对同一个会话给出彼此矛盾的答案。
|
**共享谓词由谁拥有?** 已存储字段在写入时编码规则,写入方只看到一个批次,而已附加摘要折叠整份日志。两者必须使用同一个导出的事件谓词或 reducer,避免新的消息来源变体让已附加排序与冷排序发生分歧。
|
||||||
|
|
||||||
**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
|
**该字段引入之前的日志表现如何?** 既有产物里没有这个值。回退到 mtime 能让它们保持今天的准确度;回退到 `createdAt` 是诚实的,但会把选择器和会话树里每一个既有会话都重新排一次序。
|
||||||
|
|
||||||
@@ -39,28 +39,29 @@ mtime 回答的是另一个问题:这份产物上次是什么时候被写入
|
|||||||
|
|
||||||
**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
|
**仅在确实发生了修复时才写入边界。** 这能降低出现频率,而[边界 Agent Note](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)已经否决过它:谓词对有序重启同样必须成立。用一条正确性不变式去换时间戳的准确度,方向是错的。
|
||||||
|
|
||||||
**从投影缓存派生活动时间。** `session-projection-cache` 本就会折叠水位线之后的尾部,因此一个最后活动单元可以搭乘既有机制。它作为主形态被否决,因为该缓存是一个可选的组合项;只有挂载了缓存插件才提供的列举,会让排序取决于如何组合。
|
**从投影缓存派生活动时间。** 这是当前的过渡实现。`session-projection-cache` 会折叠水位线之后的尾部,无需改变持久格式,但它是可选且 fail-soft 的。缺失或 checkpoint 延迟会让排序取决于 cache 是否存在以及是否新鲜,因此无法提供本文所提议的权威值。
|
||||||
|
|
||||||
## 验收标准
|
## 验收标准
|
||||||
|
|
||||||
- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值;验证方式是恢复、不跑轮次就退出,并断言两条路径上的顺序都没有变化。
|
- 冷会话的 `SessionSummary.updatedAt` 等于已附加会话的投影为同一个会话报告的那个值;验证方式是恢复、不跑轮次就退出,并断言两条路径上的顺序都没有变化。
|
||||||
- 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。
|
- 在 web 会话树和 TUI 恢复选择器中,一个恢复后即被弃置的会话不会排到此后工作过的会话之前;由一份组装后的快照钉住,而不是只靠单元测试。
|
||||||
- 活动规则只有一个定义:一个测试证明,在一份同时包含边界、closer 和一个普通轮次的日志上,已存储字段与 `lastActivityTime()` 的结果一致。
|
- prompt 时间规则只有一个定义:一个测试证明,在包含真人 prompt、注入式 user message、边界和 closer 的日志上,已存储字段与已附加折叠结果一致。
|
||||||
- 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。
|
- 在选定的回退方案下,该字段引入之前的产物能够无错误地加载和列举,并且该回退在排序上的后果有断言覆盖。
|
||||||
- 按本仓库不做迁移的立场,SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。
|
- 按本仓库不做迁移的立场,SQLite 的 `SCHEMA_VERSION` 递增会拒绝旧的磁盘版本。
|
||||||
|
|
||||||
## 风险
|
## 风险
|
||||||
|
|
||||||
**「活动」的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新事件类型若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生一个冷排序与已附加排序彼此矛盾的会话;这个缺陷只在重启之后才显现,而那正是最难被注意到的地方。
|
**prompt 时间的两个定义发生漂移。** 已存储字段按批次计算,而投影在整份日志上计算。一种新消息来源若在写入时按一种方式归类、在读取时按另一种方式归类,就会产生冷排序与已附加排序彼此矛盾的 Session;该缺陷只会在重启后显现。
|
||||||
|
|
||||||
**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃,会留下一个陈旧的值,而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待,而这与 mtime 今天的地位已经很接近了。
|
**JSONL 的伴随文件可能与它的日志不一致。** 在日志追加与伴随文件写入之间发生崩溃,会留下一个陈旧的值,而且没有撕裂尾部标记可用来修复它。每个消费方都得把伴随文件当作一条提示来对待,而这与 mtime 今天的地位已经很接近了。
|
||||||
|
|
||||||
**回退方案会让既有会话重新排序。** 无论选定哪种回退,持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。
|
**回退方案会让既有会话重新排序。** 无论选定哪种回退,持有既有日志的用户都会在升级时看到自己的选择器和会话树重新排一次序。选 `createdAt` 会让这次重排的幅度很大。
|
||||||
|
|
||||||
**代价可能超过这个缺陷本身。** 该缺陷是被弃置会话的排序出错。如果对 JSONL 来说诚实的答案是「保留这套近似」,那么本文的结局可能是记录下这个决定,而不是实现一个字段,而这也是一个可以接受的结局。
|
**代价可能超过这个缺陷本身。** 剩余缺陷是 projection metadata 缺失或延迟时的保守错序。如果对 JSONL 来说诚实的答案是「保留 cache 回退」,那么本文的结局可能是记录该决定,而不是实现一个字段。
|
||||||
|
|
||||||
## 相关
|
## 相关
|
||||||
|
|
||||||
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——mtime 会计入的非活动写入之一;`dsh-session` 拥有 `lastActivityTime()`,也就是一个已存储字段必须与之保持一致的那个日志内投影。
|
- [有界冷空白验证](../../implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)——移除 mtime 排序,定义 projection cache 的过渡回退,并把直接冷读取限制为空白检查。
|
||||||
|
- [种子结束日志边界](../../implemented/architecture/2026-07-30-session-end-seed-log-boundary.md)——让 mtime 不适用的非 prompt 写入之一。
|
||||||
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
|
- [会话持久化](../../implemented/architecture/2026-06-14-session-persistence.md)——仅追加与绝不重写这两条不变式,正是它们排除了可变的 JSONL header 字段。
|
||||||
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
|
- [共享持久化写入协调器](../../implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md)——一个已存储字段将挂入的那条追加路径。
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/** Cold Session list visibility through the shipped compressed JSONL backend. */
|
||||||
|
|
||||||
|
import { mkdir, stat } from 'node:fs/promises'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import type { Browser, Page } from 'playwright'
|
||||||
|
import { chromium } from 'playwright'
|
||||||
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||||
|
import {
|
||||||
|
captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedBlankSession,
|
||||||
|
watchConsole, webSnapshotMode, type WebScaffold,
|
||||||
|
} from './scaffold.ts'
|
||||||
|
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||||
|
|
||||||
|
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/cold-blank-session', import.meta.url))
|
||||||
|
const SIDEBAR_EXPECTED = join(SNAPSHOT_DIR, 'sidebar.expected.md')
|
||||||
|
const MODE = webSnapshotMode()
|
||||||
|
const SESSION_ID = 'cold-blank-session-web-e2e'
|
||||||
|
const WORKSPACE_NAME = 'cold-blank-workspace'
|
||||||
|
|
||||||
|
describe('web e2e: cold blank Session visibility', () => {
|
||||||
|
let scaffold: WebScaffold
|
||||||
|
let browser: Browser
|
||||||
|
let page: Page
|
||||||
|
let tripwire: ReturnType<typeof watchConsole>
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
scaffold = await launchWebScaffold({})
|
||||||
|
const cwd = join(scaffold.workspaceCwd, WORKSPACE_NAME)
|
||||||
|
await mkdir(cwd, { recursive: true })
|
||||||
|
await seedBlankSession(scaffold, SESSION_ID, cwd)
|
||||||
|
const header = (await scaffold.ctx.sessionPersistence.list())
|
||||||
|
.find(candidate => candidate.id === SESSION_ID)
|
||||||
|
if (header === undefined) throw new Error('blank Session fixture did not materialize')
|
||||||
|
const location = scaffold.ctx.sessionPersistence.locate(header)
|
||||||
|
if (location === undefined) throw new Error('JSONL fixture has no physical artifact')
|
||||||
|
expect((await stat(location.path)).size).toBeLessThanOrEqual(1024)
|
||||||
|
|
||||||
|
browser = await chromium.launch()
|
||||||
|
page = await newEnglishPage(browser)
|
||||||
|
tripwire = watchConsole(page)
|
||||||
|
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||||
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||||
|
}, 120_000)
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await browser?.close()
|
||||||
|
await scaffold?.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the verified cold blank Session out of the sidebar', async () => {
|
||||||
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-cold-blank-session'))
|
||||||
|
const tree = page.getByRole('tree', { name: 'Sessions' })
|
||||||
|
await tree.waitFor({ timeout: 30_000 })
|
||||||
|
expect(await tree.getByText(WORKSPACE_NAME, { exact: true }).count()).toBe(0)
|
||||||
|
const sidebar = await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd)
|
||||||
|
await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
|
||||||
|
expect(tripwire.pageErrors).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
// (the plugin-row path discards the ReplayHandle; the direct install keeps
|
||||||
// assertConsumed for the teardown fixture-consumption check).
|
// assertConsumed for the teardown fixture-consumption check).
|
||||||
import { existsSync } from 'node:fs'
|
import { existsSync } from 'node:fs'
|
||||||
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, utimes, writeFile } from 'node:fs/promises'
|
import { mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { pathToFileURL } from 'node:url'
|
import { pathToFileURL } from 'node:url'
|
||||||
@@ -724,6 +724,38 @@ export async function seedSession(
|
|||||||
delegationDepth: 0,
|
delegationDepth: 0,
|
||||||
...agentPreset === undefined ? {} : { agentPreset },
|
...agentPreset === undefined ? {} : { agentPreset },
|
||||||
}
|
}
|
||||||
|
await persistSeedSession(scaffold, meta, events)
|
||||||
|
return meta.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seed one materialized cold Session whose log has no turn/start event. */
|
||||||
|
export async function seedBlankSession(
|
||||||
|
scaffold: WebScaffold,
|
||||||
|
id: string,
|
||||||
|
cwd: string,
|
||||||
|
): Promise<SessionId> {
|
||||||
|
const meta: SessionHeader = {
|
||||||
|
version: SESSION_FORMAT_VERSION,
|
||||||
|
id: SessionId(id),
|
||||||
|
createdAt: Date.now() - 60_000,
|
||||||
|
cwd,
|
||||||
|
delegationDepth: 0,
|
||||||
|
}
|
||||||
|
await persistSeedSession(scaffold, meta, [{
|
||||||
|
type: 'session/end-seed',
|
||||||
|
seq: 0,
|
||||||
|
time: meta.createdAt,
|
||||||
|
data: {},
|
||||||
|
}])
|
||||||
|
return meta.id
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Materialize one detached Session fixture through the shipped JSONL provider. */
|
||||||
|
async function persistSeedSession(
|
||||||
|
scaffold: WebScaffold,
|
||||||
|
meta: SessionHeader,
|
||||||
|
events: readonly SessionEvent[],
|
||||||
|
): Promise<void> {
|
||||||
const seeder = new Context()
|
const seeder = new Context()
|
||||||
try {
|
try {
|
||||||
await seeder.plugin(SessionStore)
|
await seeder.plugin(SessionStore)
|
||||||
@@ -732,16 +764,9 @@ export async function seedSession(
|
|||||||
await seeder.plugin(JsonlSessionPersistence, { root: scaffold.persistenceRoot })
|
await seeder.plugin(JsonlSessionPersistence, { root: scaffold.persistenceRoot })
|
||||||
await seeder.sessionPersistence.create(meta)
|
await seeder.sessionPersistence.create(meta)
|
||||||
await seeder.sessionPersistence.append(meta.id, events)
|
await seeder.sessionPersistence.append(meta.id, events)
|
||||||
// Deterministic sidebar order: cold summaries take updatedAt from mtime.
|
|
||||||
const located = seeder.sessionPersistence.locate(meta)
|
|
||||||
if (located !== undefined) {
|
|
||||||
const backdated = new Date(meta.createdAt)
|
|
||||||
await utimes(located.path, backdated, backdated)
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await seeder.fiber.dispose()
|
await seeder.fiber.dispose()
|
||||||
}
|
}
|
||||||
return meta.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
- tree "Sessions": No sessions yet
|
||||||
@@ -48,6 +48,7 @@
|
|||||||
"tests/replay-round-trip.e2e.ts",
|
"tests/replay-round-trip.e2e.ts",
|
||||||
"tests/hmr-live.e2e.ts",
|
"tests/hmr-live.e2e.ts",
|
||||||
"tests/seeded-history.e2e.ts",
|
"tests/seeded-history.e2e.ts",
|
||||||
|
"tests/cold-blank-session.e2e.ts",
|
||||||
"tests/stats-paged-history.e2e.ts",
|
"tests/stats-paged-history.e2e.ts",
|
||||||
"tests/sidebar-scrollbar.e2e.ts",
|
"tests/sidebar-scrollbar.e2e.ts",
|
||||||
"tests/conversation-column-overflow.e2e.ts",
|
"tests/conversation-column-overflow.e2e.ts",
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||||
config-catalog.md: 19bfa6d1fb847de4a7207f42dabd67d43f288361
|
config-catalog.md: 4e7039968dba5409d07a7f1fbbee2d77c9a5d7f7
|
||||||
config-catalog.zh.md: fda208e8fcd2ff6dd697efed84a4073ecbd5a912
|
config-catalog.zh.md: 3c43a09c359e2bd3f3985c47ba6ad402d93dd2a9
|
||||||
@@ -692,6 +692,12 @@ export interface Config {
|
|||||||
* @default 6
|
* @default 6
|
||||||
*/
|
*/
|
||||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||||
|
/**
|
||||||
|
* Maximum physical size of a cold Session artifact eligible for blankness
|
||||||
|
* verification. Zero disables probes.
|
||||||
|
* @default 1024
|
||||||
|
*/
|
||||||
|
coldBlankProbeMaxBytes?: number
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -694,6 +694,12 @@ export interface Config {
|
|||||||
* @default 6
|
* @default 6
|
||||||
*/
|
*/
|
||||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||||
|
/**
|
||||||
|
* Maximum physical size of a cold Session artifact eligible for blankness
|
||||||
|
* verification. Zero disables probes.
|
||||||
|
* @default 1024
|
||||||
|
*/
|
||||||
|
coldBlankProbeMaxBytes?: number
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||||
README.md: 9467ec288ae597a43eaf954393005ef81ec02c66
|
README.md: 518a7e5640bc62b493244a3d863cfea643386f7f
|
||||||
README.zh.md: 8194bf0a72f52cf9824a067f12040167eaf005da
|
README.zh.md: 5692e9441d20d9dbec5d8a69263c875eb3f3f907
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
The API gateway shared by every client consists of the TypeScript API contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}`, provides `ctx.apiProxy`). This package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle.
|
||||||
|
|
||||||
## The shared Agent default (`agent-default-model` Settings section)
|
## The shared Agent default (`agent-default-model` Settings section)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
|
|||||||
|
|
||||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compaction/summary` record on the same page as the replacement that cites it.
|
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compaction/summary` record on the same page as the replacement that cites it.
|
||||||
|
|
||||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
|
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway owns two units: `sessionListMetadata` caches the monotonic blank-to-nonblank transition and latest human prompt time used by `session.list`, while `imageLimits` publishes the attachments config enforced at prompt admission as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the latter activates only while both the registry and the attachments service are composed.
|
||||||
|
|
||||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). `HEAD` runs the same root preparation and returns its status and headers without a response body, so browser clients can detect pre-stream failures before handing the GET to the native download manager. Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Pending queued input is a live control-plane contract, not conversation history.
|
|||||||
|
|
||||||
Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
|
Background jobs ride the same live-push posture. When `ctx.jobs` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/jobs` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `JobView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames.
|
||||||
|
|
||||||
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`.
|
Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` commits one registry-order move and answers the complete order; a pure reorder emits `host/workspace-order-changed` with that complete order, while unknown sources or anchors return `workspace-not-found`. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry whether a turn has started: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority. Attached summaries fold the live log. A cold summary trusts cached `blank: false`, but treats cached `true` and a cache miss as unverified; when `locate()` resolves an artifact no larger than `coldBlankProbeMaxBytes` (default 1 KiB), the gateway reads that Session with `readFrom()` and checks for `turn/start`. A larger, location-less, vanished, or unreadable artifact remains visible. `updatedAt` is the latest human `user/message` time from the live fold or projection cache, falling back to `createdAt`; pickup boundaries and other writes never promote a Session.
|
||||||
|
|
||||||
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
`session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking.
|
||||||
|
|
||||||
@@ -80,4 +80,4 @@ None; this package neither assembles nor sends a provider request.
|
|||||||
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
- **No protocol version field** — client and host ship together; `host.describe` gains a version negotiation field only when an independently released client exists.
|
||||||
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
- **Search failures include provider diagnostics** — the gateway is a single-user local service. A carrier that exposes it to multiple users must replace internal search details with a public-safe diagnostic.
|
||||||
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
|
- **Linux native picker requires desktop tooling** — under the `native` capability, `host.pickDirectory` reports an actionable error when neither Zenity nor KDialog is installed; the browse backend is the composition-level fallback (see the [native backend README](../directory-picker-native/README.md)).
|
||||||
- **A cold session's `updatedAt` counts a mere pickup as a write (per-file backends only)** — the attached projection excludes the `session/end-seed` boundary, because picking a session up is not activity, but a cold session's `updatedAt` is its log file's mtime and every durable write refreshes that, the boundary included. `agentFor()` resumes a cold session on first touch, so merely opening one in a client writes it. This applies only where `locate()` resolves a per-session artifact, i.e. JSONL; SQLite returns `undefined`, so its cold sessions fall back to `createdAt` and are skewed the other way — too old rather than too new — independently of this boundary. A session touched without being worked in therefore sorts newer than its last real activity until it attaches. Separating the two needs a log read, which is exactly what the mtime path exists to avoid; a stored last-activity field in the index would fix it at the source, scoped in the [last-activity-index Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
- **Cold-list hints degrade only toward visibility and older ordering** — a projection-cache miss or stale `lastPromptAt` falls back to `createdAt`, so a recently worked Session may sort too low until the next checkpoint. A blank artifact larger than `coldBlankProbeMaxBytes`, or one from a backend without `locate()`, remains visible because the gateway cannot verify the absence of `turn/start` within the read bound. The [bounded blank-verification decision](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md) owns this safety direction; an authoritative exact recency index remains scoped in the [last-activity-index proposal](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md).
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
所有客户端共用的 API 网关由三部分组成:TypeScript API 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?, sessionExportCompressionLevel?, coldBlankProbeMaxBytes?}`,提供 `ctx.apiProxy`)。该包不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。
|
||||||
|
|
||||||
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
## 共享 Agent 默认值(`agent-default-model` Settings 分节)
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
|||||||
|
|
||||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compaction/summary` 记录与引用它的替换留在同一页。
|
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compaction/summary` 记录与引用它的替换留在同一页。
|
||||||
|
|
||||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
|
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关拥有两个单元:`sessionListMetadata` 缓存用于 `session.list` 的单调 blank→nonblank 转换与最新真人 prompt 时间;`imageLimits` 则把 prompt 准入时执行的 attachments 配置作为每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限,后者仅在注册表与 attachments 服务同时组合时激活。
|
||||||
|
|
||||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。`HEAD` 会执行相同的根工件准备,并在没有响应 body 的情况下返回状态与响应头,使浏览器 Client 可以在把 GET 交给原生下载管理器前发现流式传输前的失败。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
|||||||
|
|
||||||
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
后台任务沿用同一种实时推送姿态。当组合中有 `ctx.jobs` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/jobs` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `JobView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||||
|
|
||||||
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。
|
Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.insertBefore({ workspaceId, beforeWorkspaceId? })` 提交一次注册表顺序移动并应答完整顺序;单纯重排序会通过 `host/workspace-order-changed` 推送同一份完整顺序,而未知来源或锚点返回 `workspace-not-found`。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非活动会话也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带是否已开始过轮次:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威。已附加摘要折叠实时日志。冷摘要信任缓存的 `blank: false`,但把缓存的 `true` 与 cache miss 都视为未经验证;当 `locate()` 解析出的工件不大于 `coldBlankProbeMaxBytes`(默认 1 KiB)时,网关通过 `readFrom()` 读取该 Session 并检查 `turn/start`。更大、无位置、已消失或不可读的工件保持可见。`updatedAt` 取实时折叠或投影缓存中的最新真人 `user/message` 时间,缺失时回退到 `createdAt`;拾起边界及其他写入都不会提升 Session 排序。
|
||||||
|
|
||||||
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
`session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。
|
||||||
|
|
||||||
@@ -80,4 +80,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
|||||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||||
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
- **Linux 原生选择器依赖桌面工具**:在 `native` 能力下,Zenity 和 KDialog 均未安装时,`host.pickDirectory` 会给出包含解决建议的错误提示;组合层面的回退是 browse 后端(见 [native 后端 README](../directory-picker-native/README.md))。
|
||||||
- **冷会话的 `updatedAt` 会把一次单纯的拾起算作写入(仅逐文件后端)**:已附加投影排除了 `session/end-seed` 边界,因为接手一个会话不算活动;但冷会话的 `updatedAt` 取自其日志文件的 mtime,而每一次持久写入都会刷新它,包括这条边界。`agentFor()` 会在首次触碰时恢复一个冷会话,因此在客户端里仅仅打开一个会话就会写入它。这只适用于 `locate()` 能解析出逐会话产物的场景,即 JSONL;SQLite 返回 `undefined`,因此它的冷会话回退到 `createdAt`,偏差方向相反——偏旧而不是偏新——且与这条边界无关。于是一个被触碰过却没有在里面工作过的会话,在重新附加之前会按晚于其最后一次真实活动的时间排序。要把两者区分开需要读取日志,而这恰恰是 mtime 路径存在的目的;在索引中存储一个最后活动字段可以从源头修好它,范围见 [最后活动索引 Agent Note](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)。
|
- **冷列表提示只向“保持可见、排序偏旧”降级**:projection cache miss 或陈旧的 `lastPromptAt` 会回退到 `createdAt`,因此最近工作过的 Session 可能在下一个 checkpoint 前排得偏低。大于 `coldBlankProbeMaxBytes` 的空白工件,或来自不提供 `locate()` 的后端的空白工件会保持可见,因为网关无法在读取上限内验证其中不存在 `turn/start`。[有界空白验证决策](../../../.agents/notes/implemented/bug-fix/2026-08-13-bounded-cold-blank-verification.md)规定了这个安全方向;权威且精确的最近时间索引仍属于[最后活动索引提案](../../../.agents/notes/proposed/architecture/2026-07-29-durable-last-activity-index.md)的范围。
|
||||||
@@ -15,7 +15,7 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
|||||||
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||||
import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
|
import { isAppendSurfaceEvent, isJsonValue } from '@deepseek-ai/dsh-session'
|
||||||
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
|
||||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||||
@@ -38,7 +38,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
|||||||
import type {
|
import type {
|
||||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||||
ModelCatalogFailure, ModelProviderGroup,
|
ModelCatalogFailure, ModelProviderGroup,
|
||||||
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem,
|
||||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
|
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
|
||||||
WorkspaceId, WorkspaceView,
|
WorkspaceId, WorkspaceView,
|
||||||
} from './api/index.ts'
|
} from './api/index.ts'
|
||||||
@@ -90,7 +90,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
|
|||||||
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||||
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
||||||
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
|
import { imageLimitsProjectionSchema, sessionListMetadataProjectionSchema } from './api/sessions.schema.ts'
|
||||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||||
import { RpcId } from './api/rpc.ts'
|
import { RpcId } from './api/rpc.ts'
|
||||||
@@ -132,6 +132,8 @@ const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
|
|||||||
|
|
||||||
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
|
/** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
|
||||||
const COLD_SUMMARY_BATCH_SIZE = 16
|
const COLD_SUMMARY_BATCH_SIZE = 16
|
||||||
|
/** Default maximum artifact size eligible for one cold blankness read. */
|
||||||
|
export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024
|
||||||
|
|
||||||
/** Conversation message event types (the pagination counting unit). */
|
/** Conversation message event types (the pagination counting unit). */
|
||||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
|
||||||
@@ -506,6 +508,29 @@ function sessionBlank(session: Session): boolean {
|
|||||||
return !session.events.some(event => event.type === 'turn/start')
|
return !session.events.some(event => event.type === 'turn/start')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Advance the Session-list hint projection by one committed event. */
|
||||||
|
function applySessionListMetadata(state: SessionListMetadata, event: SessionEvent): SessionListMetadata {
|
||||||
|
const blank = state.blank && event.type !== 'turn/start'
|
||||||
|
const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
|
||||||
|
? event.time
|
||||||
|
: state.lastPromptAt
|
||||||
|
return blank === state.blank && lastPromptAt === state.lastPromptAt
|
||||||
|
? state
|
||||||
|
: { blank, lastPromptAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fold exact list metadata for an attached Session. */
|
||||||
|
function sessionListMetadata(events: readonly SessionEvent[]): SessionListMetadata {
|
||||||
|
let state: SessionListMetadata = { blank: true, lastPromptAt: null }
|
||||||
|
for (const event of events) state = applySessionListMetadata(state, event)
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sort by creation or latest human prompt, whichever is newer. */
|
||||||
|
function sessionListUpdatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number {
|
||||||
|
return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
/** Shared Session-header projection for list baselines and creation frames. */
|
/** Shared Session-header projection for list baselines and creation frames. */
|
||||||
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
|
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
|
||||||
parentSessionId?: SessionId
|
parentSessionId?: SessionId
|
||||||
@@ -527,47 +552,71 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
|
|||||||
|
|
||||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||||
function summarize(session: Session, running: boolean): SessionSummary {
|
function summarize(session: Session, running: boolean): SessionSummary {
|
||||||
|
const metadata = sessionListMetadata(session.events)
|
||||||
return {
|
return {
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
// Excludes end-seed: a resumed-but-untouched session
|
updatedAt: sessionListUpdatedAt(session.header, metadata),
|
||||||
// must not sort as freshly worked in.
|
|
||||||
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
|
||||||
running,
|
running,
|
||||||
blank: sessionBlank(session),
|
blank: metadata.blank,
|
||||||
...sessionListFields(session.header, session.events),
|
...sessionListFields(session.header, session.events),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SessionSummary projection for cold (persisted, unattached) sessions.
|
* Verify a possibly blank cold Session only when its physical artifact is
|
||||||
* updatedAt is the log file's mtime; backends without a per-session file
|
* within the configured per-Session read bound. A stale `blank: true`, an
|
||||||
* (locate() undefined) fall back to the header's createdAt.
|
* absent cache row, a large or location-less artifact, and read failures all
|
||||||
|
* resolve to visible (`false`); listing must never hide a conversation on a
|
||||||
|
* cache hint or an unavailable optimization.
|
||||||
*/
|
*/
|
||||||
async function summarizeCold(
|
async function probeColdSessionBlank(
|
||||||
|
ctx: Context,
|
||||||
persistence: SessionPersistence,
|
persistence: SessionPersistence,
|
||||||
meta: SessionHeader,
|
meta: SessionHeader,
|
||||||
|
maxBytes: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (maxBytes === 0) return false
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
const location = persistence.locate(meta)
|
||||||
|
if (location === undefined) return false
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
let size: number
|
||||||
|
try {
|
||||||
|
size = (await stat(location.path)).size
|
||||||
|
} catch {
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (size > maxBytes) return false
|
||||||
|
try {
|
||||||
|
const { events } = await persistence.readFrom(meta.id, 0, signal)
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
return !events.some(event => event.type === 'turn/start')
|
||||||
|
} catch (error) {
|
||||||
|
signal?.throwIfAborted()
|
||||||
|
ctx.logger.warn(`session.list: blank probe for "${meta.id}" failed (serving it as visible): ${String(error)}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SessionSummary projection for a cold persisted Session. */
|
||||||
|
async function summarizeCold(
|
||||||
|
ctx: Context,
|
||||||
|
persistence: SessionPersistence,
|
||||||
|
meta: SessionHeader,
|
||||||
|
metadata: SessionListMetadata | undefined,
|
||||||
|
blankProbeMaxBytes: number,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<SessionSummary> {
|
): Promise<SessionSummary> {
|
||||||
signal?.throwIfAborted()
|
const blank = metadata?.blank === false
|
||||||
let updatedAt = meta.createdAt
|
? false
|
||||||
const location = persistence.locate(meta)
|
: await probeColdSessionBlank(ctx, persistence, meta, blankProbeMaxBytes, signal)
|
||||||
signal?.throwIfAborted()
|
|
||||||
if (location !== undefined) {
|
|
||||||
try {
|
|
||||||
updatedAt = (await stat(location.path)).mtimeMs
|
|
||||||
} catch {
|
|
||||||
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
|
|
||||||
}
|
|
||||||
signal?.throwIfAborted()
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
sessionId: meta.id,
|
sessionId: meta.id,
|
||||||
updatedAt,
|
updatedAt: sessionListUpdatedAt(meta, metadata),
|
||||||
running: false,
|
running: false,
|
||||||
// Lazy persistence keeps never-appended sessions out of list(); reading
|
blank,
|
||||||
// a cold log to check for turns would defeat the index read, so a listed
|
|
||||||
// cold session is served as not-blank (its log holds its conversation).
|
|
||||||
blank: false,
|
|
||||||
// Header-only: reading the log for a blank-window preset switch would
|
// Header-only: reading the log for a blank-window preset switch would
|
||||||
// defeat the same index read, and attaching the session replaces this row
|
// defeat the same index read, and attaching the session replaces this row
|
||||||
// with `summarize()`, which resolves the switch from the events.
|
// with `summarize()`, which resolves the switch from the events.
|
||||||
@@ -608,6 +657,8 @@ export interface ApiProxyDefaults {
|
|||||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||||
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
||||||
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
||||||
|
/** Maximum artifact size eligible for one cold blankness read. */
|
||||||
|
coldBlankProbeMaxBytes?: number
|
||||||
/**
|
/**
|
||||||
* Whether handing a path to the native opener can work at all — the
|
* Whether handing a path to the native opener can work at all — the
|
||||||
* `hasDocument` capability the preset roster reports, and the switch
|
* `hasDocument` capability the preset roster reports, and the switch
|
||||||
@@ -1055,6 +1106,8 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
|
|||||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||||
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
||||||
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
||||||
|
const coldBlankProbeMaxBytes = defaults.coldBlankProbeMaxBytes
|
||||||
|
?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES
|
||||||
/** The seed model each create/resume declares; re-read so it never goes stale. */
|
/** The seed model each create/resume declares; re-read so it never goes stale. */
|
||||||
const agentOptions = (): AgentOptions => {
|
const agentOptions = (): AgentOptions => {
|
||||||
const { provider, model } = defaults.defaultModelSelection()
|
const { provider, model } = defaults.defaultModelSelection()
|
||||||
@@ -1233,6 +1286,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The cache supplies recency and a monotonic non-blank hint. A cached
|
||||||
|
// `blank: true` remains only a prefix fact and is verified on the cold path.
|
||||||
|
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||||
|
projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
|
||||||
|
key: 'sessionListMetadata',
|
||||||
|
schema: sessionListMetadataProjectionSchema,
|
||||||
|
init: () => ({ blank: true, lastPromptAt: null }),
|
||||||
|
apply: applySessionListMetadata,
|
||||||
|
view: state => state,
|
||||||
|
stateVersion: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// The imageLimits projection unit: the attachments config this proxy
|
// The imageLimits projection unit: the attachments config this proxy
|
||||||
// enforces at prompt admission, constant per host boot. `apply` keeps the
|
// enforces at prompt admission, constant per host boot. `apply` keeps the
|
||||||
// same state reference for every event, so no change frames are ever
|
// same state reference for every event, so no change frames are ever
|
||||||
@@ -1678,11 +1744,18 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
|||||||
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
|
||||||
const settled = await Promise.allSettled(
|
const settled = await Promise.allSettled(
|
||||||
batch.map(async (meta) => {
|
batch.map(async (meta) => {
|
||||||
// Cold rows read the persisted projection cache only — never a
|
// Projection hints remain optional. Blank verification may read
|
||||||
// log load; a session without a cache row simply has no column.
|
// this Session's artifact only when it fits the configured bound.
|
||||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||||
return {
|
return {
|
||||||
...await summarizeCold(persistence, meta, signal),
|
...await summarizeCold(
|
||||||
|
ctx,
|
||||||
|
persistence,
|
||||||
|
meta,
|
||||||
|
projections?.values.sessionListMetadata,
|
||||||
|
coldBlankProbeMaxBytes,
|
||||||
|
signal,
|
||||||
|
),
|
||||||
...projections === undefined ? {} : { projections },
|
...projections === undefined ? {} : { projections },
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export interface ApiProxy {
|
|||||||
export type {
|
export type {
|
||||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
|
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
|
||||||
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
||||||
} from './sessions.ts'
|
} from './sessions.ts'
|
||||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
|||||||
import type { Wire } from './rpc.schema.ts'
|
import type { Wire } from './rpc.schema.ts'
|
||||||
import type {
|
import type {
|
||||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
ModelReasoningEffort, ModelSelection, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||||
} from './sessions.ts'
|
} from './sessions.ts'
|
||||||
import type { ToolEventView } from './events.ts'
|
import type { ToolEventView } from './events.ts'
|
||||||
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||||
@@ -215,6 +215,12 @@ export const sessionProjectionsBlockSchema = z.object({
|
|||||||
values: z.record(z.string(), z.unknown()),
|
values: z.record(z.string(), z.unknown()),
|
||||||
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
|
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
|
||||||
|
|
||||||
|
/** Host-side validation for the persisted Session-list projection. */
|
||||||
|
export const sessionListMetadataProjectionSchema: z.ZodType<SessionListMetadata> = z.object({
|
||||||
|
blank: z.boolean(),
|
||||||
|
lastPromptAt: z.number().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* imageLimits projection unit schema (host-side view validation). zod widens
|
* imageLimits projection unit schema (host-side view validation). zod widens
|
||||||
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
|
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import type { WorkspaceId } from './workspace.ts'
|
|||||||
|
|
||||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||||
interface SessionProjectionMap {
|
interface SessionProjectionMap {
|
||||||
|
/**
|
||||||
|
* Session-list hints persisted by the projection cache. `blank: false`
|
||||||
|
* is monotonic and may suppress a cold-log probe; `blank: true` is only a
|
||||||
|
* checkpoint-prefix fact and must not hide a cold Session without direct
|
||||||
|
* verification. `lastPromptAt` is the latest human-authored prompt time.
|
||||||
|
*/
|
||||||
|
sessionListMetadata: SessionListMetadata
|
||||||
/**
|
/**
|
||||||
* The deployment's image-intake limits: the attachments service's config
|
* The deployment's image-intake limits: the attachments service's config
|
||||||
* as this proxy enforces it at prompt admission, constant per host boot.
|
* as this proxy enforces it at prompt admission, constant per host boot.
|
||||||
@@ -28,6 +35,14 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Persisted hints used to summarize a cold Session without reading a large log. */
|
||||||
|
export interface SessionListMetadata {
|
||||||
|
/** Whether the checkpoint prefix contains no turn/start event. */
|
||||||
|
blank: boolean
|
||||||
|
/** Latest source.kind=user message time in the checkpoint prefix. */
|
||||||
|
lastPromptAt: number | null
|
||||||
|
}
|
||||||
|
|
||||||
declare module '@deepseek-ai/dsh-llm' {
|
declare module '@deepseek-ai/dsh-llm' {
|
||||||
interface MessageSourceMap {
|
interface MessageSourceMap {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
|||||||
import z from '@deepseek-ai/schemastery'
|
import z from '@deepseek-ai/schemastery'
|
||||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||||
import type { ApiProxy } from './api/index.ts'
|
import type { ApiProxy } from './api/index.ts'
|
||||||
import { createApiProxy } from './api-proxy.ts'
|
import { createApiProxy, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './api-proxy.ts'
|
||||||
import {
|
import {
|
||||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||||
type SessionLogCompressionLevel,
|
type SessionLogCompressionLevel,
|
||||||
@@ -53,6 +53,12 @@ export interface Config {
|
|||||||
* @default 6
|
* @default 6
|
||||||
*/
|
*/
|
||||||
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
sessionExportCompressionLevel?: 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
|
||||||
|
/**
|
||||||
|
* Maximum physical size of a cold Session artifact eligible for blankness
|
||||||
|
* verification. Zero disables probes.
|
||||||
|
* @default 1024
|
||||||
|
*/
|
||||||
|
coldBlankProbeMaxBytes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -70,6 +76,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
|||||||
nativeOpen: z.boolean(),
|
nativeOpen: z.boolean(),
|
||||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||||
|
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
|
||||||
})
|
})
|
||||||
|
|
||||||
readonly sessions: ApiProxy['sessions']
|
readonly sessions: ApiProxy['sessions']
|
||||||
@@ -96,6 +103,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
|||||||
...(config.sessionExportCompressionLevel === undefined
|
...(config.sessionExportCompressionLevel === undefined
|
||||||
? {}
|
? {}
|
||||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||||
|
...(config.coldBlankProbeMaxBytes === undefined
|
||||||
|
? {}
|
||||||
|
: { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }),
|
||||||
})
|
})
|
||||||
this.sessions = api.sessions
|
this.sessions = api.sessions
|
||||||
this.subagents = api.subagents
|
this.subagents = api.subagents
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* isolation, and prompt failure mapping.
|
* isolation, and prompt failure mapping.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
@@ -13,7 +13,7 @@ import SessionStore from '@deepseek-ai/dsh-session'
|
|||||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||||
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||||
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
|
||||||
import { MessageId } from '@deepseek-ai/dsh-llm'
|
import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
|
||||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||||
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||||
@@ -39,55 +39,120 @@ function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('sessions.list cold merge', () => {
|
describe('sessions.list cold merge', () => {
|
||||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
it('verifies only small possibly-blank artifacts and treats every unavailable probe as visible', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(UserQuestionService)
|
await ctx.plugin(UserQuestionService)
|
||||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||||
const logPath = join(root, 'a.log')
|
const smallPath = join(root, 'small.log')
|
||||||
writeFileSync(logPath, 'log-bytes')
|
const largePath = join(root, 'large.log')
|
||||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
writeFileSync(smallPath, 'x'.repeat(1024))
|
||||||
|
writeFileSync(largePath, 'x'.repeat(1025))
|
||||||
const metas = [
|
const metas = [
|
||||||
header('session-a', 1000),
|
header('small-blank', 100),
|
||||||
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
header('small-conversation', 200),
|
||||||
header('session-c', 1500),
|
header('large-unknown', 300),
|
||||||
|
header('cached-nonblank', 400),
|
||||||
|
header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
||||||
|
header('vanished', 600),
|
||||||
|
header('read-failure', 700),
|
||||||
]
|
]
|
||||||
// Structural fake of the persistence face list() consumes: list + locate.
|
const readFrom = vi.fn(async (id: SessionId) => {
|
||||||
// locate: a real per-session file (mtime wins), a backend without one
|
if (id === sid('small-blank')) {
|
||||||
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
|
return {
|
||||||
// → createdAt).
|
meta: metas[0]!,
|
||||||
|
events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id === sid('small-conversation')) {
|
||||||
|
return {
|
||||||
|
meta: metas[1]!,
|
||||||
|
events: [{ type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } }] as SessionEvent[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (id === sid('read-failure')) throw new Error('simulated read failure')
|
||||||
|
throw new Error(`unexpected cold read: ${id}`)
|
||||||
|
})
|
||||||
ctx.provide('sessionPersistence', {
|
ctx.provide('sessionPersistence', {
|
||||||
list: () => Promise.resolve(metas),
|
list: () => Promise.resolve(metas),
|
||||||
locate: (meta: SessionHeader) => {
|
locate: (meta: SessionHeader) => {
|
||||||
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
|
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
|
||||||
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
if (meta.id === sid('locationless')) return undefined
|
||||||
|
if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||||
|
return { kind: 'jsonl', path: smallPath }
|
||||||
|
},
|
||||||
|
readFrom,
|
||||||
|
} as never)
|
||||||
|
ctx.provide('sessionProjectionCache', {
|
||||||
|
cachedSnapshot: (meta: SessionHeader) => {
|
||||||
|
if (meta.id === sid('small-blank')) {
|
||||||
|
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } }
|
||||||
|
}
|
||||||
|
if (meta.id === sid('small-conversation')) {
|
||||||
|
return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } }
|
||||||
|
}
|
||||||
|
if (meta.id === sid('cached-nonblank')) {
|
||||||
|
return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } }
|
||||||
|
}
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
})
|
} as never)
|
||||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||||
|
|
||||||
const response = await api.sessions.list(request({}))
|
const response = await api.sessions.list(request({}))
|
||||||
expect(response.result.ok).toBe(true)
|
expect(response.result.ok).toBe(true)
|
||||||
if (!response.result.ok) throw new Error('unreachable')
|
if (!response.result.ok) throw new Error('unreachable')
|
||||||
const items = response.result.value.items
|
const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
|
||||||
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
|
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
|
||||||
const [a, b, c] = items
|
// A stale true hint cannot hide the turn found in the bounded read.
|
||||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 900 })
|
||||||
expect(a?.running).toBe(false)
|
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
|
||||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
// false is monotonic, so this row skips stat/read and keeps cached recency.
|
||||||
// sessions out of list(), so a listed session necessarily has events.
|
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
|
||||||
expect(items.every(item => !item.blank)).toBe(true)
|
expect(byId['locationless']).toMatchObject({
|
||||||
expect(a?.cwd).toBe('/proj')
|
blank: false,
|
||||||
expect(a?.parentSessionId).toBeUndefined()
|
updatedAt: 500,
|
||||||
expect(b?.updatedAt).toBe(2000)
|
parentSessionId: 'session-parent',
|
||||||
expect(b?.parentSessionId).toBe('session-parent')
|
origin: 'subagent',
|
||||||
expect(b?.origin).toBe('subagent')
|
})
|
||||||
expect(c?.updatedAt).toBe(1500)
|
expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 })
|
||||||
|
expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 })
|
||||||
|
expect(readFrom).toHaveBeenCalledTimes(3)
|
||||||
|
expect(readFrom.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([
|
||||||
|
sid('small-blank'),
|
||||||
|
sid('small-conversation'),
|
||||||
|
sid('read-failure'),
|
||||||
|
]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can disable bounded blank probes without hiding cold Sessions', async () => {
|
||||||
|
const ctx = new Context()
|
||||||
|
await ctx.plugin(SessionStore)
|
||||||
|
await ctx.plugin(UserQuestionService)
|
||||||
|
const meta = header('probe-disabled', 100)
|
||||||
|
const readFrom = vi.fn()
|
||||||
|
ctx.provide('sessionPersistence', {
|
||||||
|
list: () => Promise.resolve([meta]),
|
||||||
|
locate: () => ({ kind: 'jsonl', path: '/not-read' }),
|
||||||
|
readFrom,
|
||||||
|
} as never)
|
||||||
|
const api = createApiProxy(ctx, {
|
||||||
|
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||||
|
cwd: '/tmp',
|
||||||
|
coldBlankProbeMaxBytes: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await api.sessions.list(request({}))
|
||||||
|
if (!response.result.ok) throw new Error('unreachable')
|
||||||
|
expect(response.result.value.items).toEqual([
|
||||||
|
expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }),
|
||||||
|
])
|
||||||
|
expect(readFrom).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('attached updatedAt excludes end-seed', () => {
|
describe('attached updatedAt tracks human prompts', () => {
|
||||||
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
|
it('ignores pickup and non-prompt work after the latest human message', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
await ctx.plugin(UserQuestionService)
|
await ctx.plugin(UserQuestionService)
|
||||||
@@ -99,7 +164,12 @@ describe('attached updatedAt excludes end-seed', () => {
|
|||||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||||
seed: [
|
seed: [
|
||||||
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
{ type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } },
|
||||||
{ type: 'turn/end', seq: 1, time: worked, data: { turn: 1, reason: { kind: 'completed' } } },
|
{
|
||||||
|
type: 'user/message', seq: 1, time: worked,
|
||||||
|
data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }),
|
||||||
|
surfaceOp: 'append',
|
||||||
|
},
|
||||||
|
{ type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||||
],
|
],
|
||||||
meta: { cwd: '/proj', createdAt: 500 },
|
meta: { cwd: '/proj', createdAt: 500 },
|
||||||
})
|
})
|
||||||
@@ -113,12 +183,21 @@ describe('attached updatedAt excludes end-seed', () => {
|
|||||||
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||||
expect(summary?.updatedAt).toBe(worked)
|
expect(summary?.updatedAt).toBe(worked)
|
||||||
|
|
||||||
// Real work appended after end-seed does move it.
|
// A lifecycle boundary is not a human update.
|
||||||
resumed.append('turn/start', { turn: 2 })
|
resumed.append('turn/start', { turn: 2 })
|
||||||
|
const afterBoundary = await api.sessions.list(request({}))
|
||||||
|
if (!afterBoundary.result.ok) throw new Error('list failed')
|
||||||
|
expect(afterBoundary.result.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt)
|
||||||
|
.toBe(worked)
|
||||||
|
|
||||||
|
const prompt = resumed.append('user/message', createUserMessage({
|
||||||
|
content: [{ type: 'text', text: 'new prompt' }],
|
||||||
|
source: { kind: 'user' },
|
||||||
|
}), { surfaceOp: 'append' })
|
||||||
const after = await api.sessions.list(request({}))
|
const after = await api.sessions.list(request({}))
|
||||||
if (!after.result.ok) throw new Error('list failed')
|
if (!after.result.ok) throw new Error('list failed')
|
||||||
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
const moved = after.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||||
expect(moved?.updatedAt).toBeGreaterThan(worked)
|
expect(moved?.updatedAt).toBe(prompt.time)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* pushed to mux consumers as a session/projection frame minted here.
|
* pushed to mux consumers as a session/projection frame minted here.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { Context } from '@deepseek-ai/cordis'
|
import { Context } from '@deepseek-ai/cordis'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||||
@@ -163,10 +163,29 @@ describe('session.history projections block', () => {
|
|||||||
dispose()
|
dispose()
|
||||||
const after = await proxy.sessions.history(request({ sessionId: session.id }))
|
const after = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||||
if (!after.result.ok) throw new Error('unreachable')
|
if (!after.result.ok) throw new Error('unreachable')
|
||||||
// The registry is still mounted, so the block itself stays (asOfSeq cut
|
// The registry stays mounted; only the disposed key leaves while the
|
||||||
// with zero keys); the disposed key reads as capability absence.
|
// gateway-owned Session-list unit remains.
|
||||||
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
|
expect(after.result.value.projections?.asOfSeq).toBe(session.seq - 1)
|
||||||
expect(after.result.value.projections?.values).toEqual({})
|
expect('test/last-user' in (after.result.value.projections?.values ?? {})).toBe(false)
|
||||||
|
expect(after.result.value.projections?.values.sessionListMetadata).toEqual({
|
||||||
|
blank: true,
|
||||||
|
lastPromptAt: session.events.at(-1)?.time,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
|
||||||
|
const { ctx, session } = await harness(true)
|
||||||
|
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||||
|
const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
|
||||||
|
createApiProxy(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||||
|
}, { inject: ['sessions', 'agents', 'userQuestions', 'sessionProjections'] }))
|
||||||
|
await fiber.await()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata)
|
||||||
|
.toEqual({ blank: true, lastPromptAt: null })
|
||||||
|
})
|
||||||
|
await fiber.dispose()
|
||||||
|
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -174,11 +193,18 @@ describe('session.list projections column', () => {
|
|||||||
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
it('serves attached rows from the live registry cut, watermarked for client seeding', async () => {
|
||||||
const { ctx, session } = await harness(true)
|
const { ctx, session } = await harness(true)
|
||||||
ctx.sessionProjections.register(lastUserUnit())
|
ctx.sessionProjections.register(lastUserUnit())
|
||||||
|
const gateway = api(ctx)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
|
session.append('turn/start', { turn: 1 })
|
||||||
seedMessages(session, 1)
|
seedMessages(session, 1)
|
||||||
const response = await api(ctx).sessions.list(request({}))
|
const response = await gateway.sessions.list(request({}))
|
||||||
if (!response.result.ok) throw new Error('unreachable')
|
if (!response.result.ok) throw new Error('unreachable')
|
||||||
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
const row = response.result.value.items.find(item => item.sessionId === session.id)
|
||||||
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' })
|
||||||
|
expect(row?.projections?.values.sessionListMetadata).toEqual({
|
||||||
|
blank: false,
|
||||||
|
lastPromptAt: session.events.at(-1)?.time,
|
||||||
|
})
|
||||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,21 +292,33 @@ describe('session/projection push frame', () => {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 0))
|
await new Promise(resolve => setTimeout(resolve, 0))
|
||||||
const abort = new AbortController()
|
const abort = new AbortController()
|
||||||
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
|
const stream = proxy.events.mux({ rpcId: RpcId('t-proj-mux'), payload: {} }, abort.signal)
|
||||||
const collected = collect(stream, 2, abort)
|
const collected = collect(stream, 5, abort)
|
||||||
|
|
||||||
|
const now = vi.spyOn(Date, 'now').mockReturnValue(100)
|
||||||
seedMessages(session, 1)
|
seedMessages(session, 1)
|
||||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
now.mockReturnValue(200)
|
||||||
session.append('turn/start', { turn: 1 })
|
session.append('turn/start', { turn: 1 })
|
||||||
|
now.mockReturnValue(300)
|
||||||
seedMessages(session, 1)
|
seedMessages(session, 1)
|
||||||
|
now.mockRestore()
|
||||||
|
|
||||||
const frames = await collected
|
const frames = await collected
|
||||||
const pushes = frames.filter(
|
const pushes = frames.filter(
|
||||||
(f): f is Extract<MuxFrame, { type: 'session/projection' }> => f.type === 'session/projection',
|
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
|
||||||
|
f.type === 'session/projection' && f.key === 'test/last-user',
|
||||||
)
|
)
|
||||||
expect(pushes).toEqual([
|
expect(pushes).toEqual([
|
||||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
|
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 },
|
||||||
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
|
{ type: 'session/projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 },
|
||||||
])
|
])
|
||||||
|
expect(frames.filter(
|
||||||
|
(f): f is Extract<MuxFrame, { type: 'session/projection' }> =>
|
||||||
|
f.type === 'session/projection' && f.key === 'sessionListMetadata',
|
||||||
|
)).toEqual([
|
||||||
|
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 },
|
||||||
|
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 },
|
||||||
|
{ type: 'session/projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 },
|
||||||
|
])
|
||||||
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
|
// Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible).
|
||||||
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
|
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||||
if (!tail.result.ok) throw new Error('unreachable')
|
if (!tail.result.ok) throw new Error('unreachable')
|
||||||
|
|||||||
@@ -95,7 +95,12 @@ function bench(options: {
|
|||||||
})
|
})
|
||||||
// The gateway's own projection push feed subscribes at construction; the
|
// The gateway's own projection push feed subscribes at construction; the
|
||||||
// no-op disposer keeps that feed quiet while these tests pin history reads.
|
// no-op disposer keeps that feed quiet while these tests pin history reads.
|
||||||
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
|
ctx.provide('sessionProjections', {
|
||||||
|
snapshot,
|
||||||
|
restore,
|
||||||
|
onChanged: () => () => {},
|
||||||
|
register: () => () => {},
|
||||||
|
})
|
||||||
ctx.provide('userQuestions', { registerProvider: () => () => {} })
|
ctx.provide('userQuestions', { registerProvider: () => () => {} })
|
||||||
const api = createApiProxy(ctx, {
|
const api = createApiProxy(ctx, {
|
||||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||||
|
|||||||
@@ -127,17 +127,32 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
|
|||||||
|
|
||||||
describe('session export compression config', () => {
|
describe('session export compression config', () => {
|
||||||
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
it('defaults to level 6 and rejects values outside the integer 0-9 range', () => {
|
||||||
expect(ApiProxyService.Config({})).toEqual({ sessionExportCompressionLevel: 6 })
|
expect(ApiProxyService.Config({})).toEqual({
|
||||||
|
sessionExportCompressionLevel: 6,
|
||||||
|
coldBlankProbeMaxBytes: 1024,
|
||||||
|
})
|
||||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 0 }))
|
||||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
|
||||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
|
||||||
for (const value of [-1, 10, 1.5]) {
|
for (const value of [-1, 10, 1.5]) {
|
||||||
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
expect(() => ApiProxyService.Config({ sessionExportCompressionLevel: value } as never)).toThrow()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('cold blank probe config', () => {
|
||||||
|
it('accepts a per-Session byte bound including zero and rejects invalid bounds', () => {
|
||||||
|
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 0 }))
|
||||||
|
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 0 })
|
||||||
|
expect(ApiProxyService.Config({ coldBlankProbeMaxBytes: 2048 }))
|
||||||
|
.toEqual({ sessionExportCompressionLevel: 6, coldBlankProbeMaxBytes: 2048 })
|
||||||
|
for (const value of [-1, 1.5]) {
|
||||||
|
expect(() => ApiProxyService.Config({ coldBlankProbeMaxBytes: value })).toThrow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('session.export download endpoint', () => {
|
describe('session.export download endpoint', () => {
|
||||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"apps/web/tests/replay-round-trip.e2e.ts",
|
"apps/web/tests/replay-round-trip.e2e.ts",
|
||||||
"apps/web/tests/hmr-live.e2e.ts",
|
"apps/web/tests/hmr-live.e2e.ts",
|
||||||
"apps/web/tests/seeded-history.e2e.ts",
|
"apps/web/tests/seeded-history.e2e.ts",
|
||||||
|
"apps/web/tests/cold-blank-session.e2e.ts",
|
||||||
"apps/web/tests/stats-paged-history.e2e.ts",
|
"apps/web/tests/stats-paged-history.e2e.ts",
|
||||||
"apps/web/tests/sidebar-scrollbar.e2e.ts",
|
"apps/web/tests/sidebar-scrollbar.e2e.ts",
|
||||||
"apps/web/tests/conversation-column-overflow.e2e.ts",
|
"apps/web/tests/conversation-column-overflow.e2e.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user