fix(web): hide verified cold blank sessions
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 9467ec288ae597a43eaf954393005ef81ec02c66
|
||||
README.zh.md: 8194bf0a72f52cf9824a067f12040167eaf005da
|
||||
README.md: 518a7e5640bc62b493244a3d863cfea643386f7f
|
||||
README.zh.md: 5692e9441d20d9dbec5d8a69263c875eb3f3f907
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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)
|
||||
|
||||
@@ -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`'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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
- **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)).
|
||||
- **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) | 中文
|
||||
|
||||
所有客户端共用的 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 分节)
|
||||
|
||||
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`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` 实现它。
|
||||
|
||||
@@ -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` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。
|
||||
|
||||
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 变量上限。
|
||||
|
||||
@@ -80,4 +80,4 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr
|
||||
- **没有协议版本字段**:客户端与宿主一同发布;只有出现独立发布的客户端后,`host.describe` 才会增加版本协商字段。
|
||||
- **搜索失败会包含提供方诊断信息**:网关是单用户本地服务。将其暴露给多名用户的载体必须用可安全公开的诊断信息替代内部搜索细节。
|
||||
- **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 { errorChain } 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 { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -38,7 +38,7 @@ import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, JobView, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} 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).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
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 type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } 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. */
|
||||
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). */
|
||||
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')
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
|
||||
parentSessionId?: SessionId
|
||||
@@ -527,47 +552,71 @@ function sessionListFields(header: SessionHeader, events: readonly SessionEvent[
|
||||
|
||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
const metadata = sessionListMetadata(session.events)
|
||||
return {
|
||||
sessionId: session.id,
|
||||
// Excludes end-seed: a resumed-but-untouched session
|
||||
// must not sort as freshly worked in.
|
||||
updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
|
||||
updatedAt: sessionListUpdatedAt(session.header, metadata),
|
||||
running,
|
||||
blank: sessionBlank(session),
|
||||
blank: metadata.blank,
|
||||
...sessionListFields(session.header, session.events),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummary projection for cold (persisted, unattached) sessions.
|
||||
* updatedAt is the log file's mtime; backends without a per-session file
|
||||
* (locate() undefined) fall back to the header's createdAt.
|
||||
* Verify a possibly blank cold Session only when its physical artifact is
|
||||
* within the configured per-Session read bound. A stale `blank: true`, an
|
||||
* 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,
|
||||
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,
|
||||
): Promise<SessionSummary> {
|
||||
signal?.throwIfAborted()
|
||||
let updatedAt = meta.createdAt
|
||||
const location = persistence.locate(meta)
|
||||
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()
|
||||
}
|
||||
const blank = metadata?.blank === false
|
||||
? false
|
||||
: await probeColdSessionBlank(ctx, persistence, meta, blankProbeMaxBytes, signal)
|
||||
return {
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
updatedAt: sessionListUpdatedAt(meta, metadata),
|
||||
running: false,
|
||||
// Lazy persistence keeps never-appended sessions out of list(); reading
|
||||
// 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,
|
||||
blank,
|
||||
// Header-only: reading the log for a blank-window preset switch would
|
||||
// defeat the same index read, and attaching the session replaces this row
|
||||
// with `summarize()`, which resolves the switch from the events.
|
||||
@@ -608,6 +657,8 @@ export interface ApiProxyDefaults {
|
||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
||||
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
|
||||
* `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 {
|
||||
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
||||
?? 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. */
|
||||
const agentOptions = (): AgentOptions => {
|
||||
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
|
||||
// enforces at prompt admission, constant per host boot. `apply` keeps the
|
||||
// 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 settled = await Promise.allSettled(
|
||||
batch.map(async (meta) => {
|
||||
// Cold rows read the persisted projection cache only — never a
|
||||
// log load; a session without a cache row simply has no column.
|
||||
// Projection hints remain optional. Blank verification may read
|
||||
// this Session's artifact only when it fits the configured bound.
|
||||
const projections = listProjectionsFor(ctx, meta, undefined)
|
||||
return {
|
||||
...await summarizeCold(persistence, meta, signal),
|
||||
...await summarizeCold(
|
||||
ctx,
|
||||
persistence,
|
||||
meta,
|
||||
projections?.values.sessionListMetadata,
|
||||
coldBlankProbeMaxBytes,
|
||||
signal,
|
||||
),
|
||||
...projections === undefined ? {} : { projections },
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ApiProxy {
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
|
||||
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
||||
SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
ModelReasoningEffort, ModelSelection, SessionListMetadata, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
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()),
|
||||
}) 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
|
||||
* `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' {
|
||||
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
|
||||
* 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' {
|
||||
interface MessageSourceMap {
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
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 {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
type SessionLogCompressionLevel,
|
||||
@@ -53,6 +53,12 @@ export interface Config {
|
||||
* @default 6
|
||||
*/
|
||||
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(),
|
||||
sessionExportCompressionLevel: z.number().step(1).min(0).max(9)
|
||||
.default(DEFAULT_SESSION_LOG_COMPRESSION_LEVEL) as z<SessionLogCompressionLevel>,
|
||||
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
|
||||
})
|
||||
|
||||
readonly sessions: ApiProxy['sessions']
|
||||
@@ -96,6 +103,9 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
...(config.sessionExportCompressionLevel === undefined
|
||||
? {}
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
...(config.coldBlankProbeMaxBytes === undefined
|
||||
? {}
|
||||
: { coldBlankProbeMaxBytes: config.coldBlankProbeMaxBytes }),
|
||||
})
|
||||
this.sessions = api.sessions
|
||||
this.subagents = api.subagents
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* isolation, and prompt failure mapping.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
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 { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol'
|
||||
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 UserQuestionService from '@deepseek-ai/dsh-user-questions'
|
||||
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', () => {
|
||||
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()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||
const logPath = join(root, 'a.log')
|
||||
writeFileSync(logPath, 'log-bytes')
|
||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
||||
const smallPath = join(root, 'small.log')
|
||||
const largePath = join(root, 'large.log')
|
||||
writeFileSync(smallPath, 'x'.repeat(1024))
|
||||
writeFileSync(largePath, 'x'.repeat(1025))
|
||||
const metas = [
|
||||
header('session-a', 1000),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent'), origin: 'subagent' }),
|
||||
header('session-c', 1500),
|
||||
header('small-blank', 100),
|
||||
header('small-conversation', 200),
|
||||
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.
|
||||
// locate: a real per-session file (mtime wins), a backend without one
|
||||
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
|
||||
// → createdAt).
|
||||
const readFrom = vi.fn(async (id: SessionId) => {
|
||||
if (id === sid('small-blank')) {
|
||||
return {
|
||||
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', {
|
||||
list: () => Promise.resolve(metas),
|
||||
locate: (meta: SessionHeader) => {
|
||||
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
|
||||
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||
if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath }
|
||||
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
|
||||
},
|
||||
})
|
||||
} as never)
|
||||
const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const items = response.result.value.items
|
||||
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
// Cold summaries are never blank: lazy persistence keeps never-appended
|
||||
// sessions out of list(), so a listed session necessarily has events.
|
||||
expect(items.every(item => !item.blank)).toBe(true)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
expect(b?.parentSessionId).toBe('session-parent')
|
||||
expect(b?.origin).toBe('subagent')
|
||||
expect(c?.updatedAt).toBe(1500)
|
||||
const byId = Object.fromEntries(response.result.value.items.map(item => [item.sessionId, item]))
|
||||
expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false })
|
||||
// A stale true hint cannot hide the turn found in the bounded read.
|
||||
expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 900 })
|
||||
expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 })
|
||||
// false is monotonic, so this row skips stat/read and keeps cached recency.
|
||||
expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 })
|
||||
expect(byId['locationless']).toMatchObject({
|
||||
blank: false,
|
||||
updatedAt: 500,
|
||||
parentSessionId: 'session-parent',
|
||||
origin: 'subagent',
|
||||
})
|
||||
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', () => {
|
||||
it('reports the last real work, not the pickup, so a resumed-untouched session does not float', async () => {
|
||||
describe('attached updatedAt tracks human prompts', () => {
|
||||
it('ignores pickup and non-prompt work after the latest human message', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(UserQuestionService)
|
||||
@@ -99,7 +164,12 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const resumed = ctx.sessions.create(sid('resumed-untouched'), {
|
||||
seed: [
|
||||
{ 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 },
|
||||
})
|
||||
@@ -113,12 +183,21 @@ describe('attached updatedAt excludes end-seed', () => {
|
||||
const summary = listed.result.value.items.find(item => item.sessionId === 'resumed-untouched')
|
||||
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 })
|
||||
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({}))
|
||||
if (!after.result.ok) throw new Error('list failed')
|
||||
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.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
@@ -163,10 +163,29 @@ describe('session.history projections block', () => {
|
||||
dispose()
|
||||
const after = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
if (!after.result.ok) throw new Error('unreachable')
|
||||
// The registry is still mounted, so the block itself stays (asOfSeq cut
|
||||
// with zero keys); the disposed key reads as capability absence.
|
||||
// The registry stays mounted; only the disposed key leaves while the
|
||||
// gateway-owned Session-list unit remains.
|
||||
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 () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
const gateway = api(ctx)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
session.append('turn/start', { turn: 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')
|
||||
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.sessionListMetadata).toEqual({
|
||||
blank: false,
|
||||
lastPromptAt: session.events.at(-1)?.time,
|
||||
})
|
||||
expect(row?.projections?.asOfSeq).toBe(session.seq - 1)
|
||||
})
|
||||
|
||||
@@ -266,21 +292,33 @@ describe('session/projection push frame', () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const abort = new AbortController()
|
||||
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)
|
||||
// Same-reference apply: turn/start does not concern the unit — no frame.
|
||||
now.mockReturnValue(200)
|
||||
session.append('turn/start', { turn: 1 })
|
||||
now.mockReturnValue(300)
|
||||
seedMessages(session, 1)
|
||||
now.mockRestore()
|
||||
|
||||
const frames = await collected
|
||||
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([
|
||||
{ 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 },
|
||||
])
|
||||
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).
|
||||
const tail = await proxy.sessions.history(request({ sessionId: session.id }))
|
||||
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
|
||||
// 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: () => () => {} })
|
||||
const api = createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
|
||||
|
||||
@@ -127,17 +127,32 @@ async function responseBytes(response: Response): Promise<Uint8Array> {
|
||||
|
||||
describe('session export compression config', () => {
|
||||
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 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 0 })
|
||||
.toEqual({ sessionExportCompressionLevel: 0, coldBlankProbeMaxBytes: 1024 })
|
||||
expect(ApiProxyService.Config({ sessionExportCompressionLevel: 9 }))
|
||||
.toEqual({ sessionExportCompressionLevel: 9 })
|
||||
.toEqual({ sessionExportCompressionLevel: 9, coldBlankProbeMaxBytes: 1024 })
|
||||
for (const value of [-1, 10, 1.5]) {
|
||||
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', () => {
|
||||
it('streams a ZIP with the root artifact verbatim under its original filename', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') })
|
||||
|
||||
Reference in New Issue
Block a user