From b92235c490e4d831a27fc2775048b9cc5434ba3e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 14:26:25 +0800 Subject: [PATCH 01/19] docs: record model-facing session query tools --- ...model-facing-session-query-tools.i18n.yaml | 6 +++ ...-07-24-model-facing-session-query-tools.md | 51 +++++++++++++++++++ ...-24-model-facing-session-query-tools.zh.md | 51 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 .agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md create mode 100644 .agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml new file mode 100644 index 0000000000..feab70bc44 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -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 +2026-07-24-model-facing-session-query-tools.md: 27842c2799c3898de4bd9f0fd8171911b0e965cd +2026-07-24-model-facing-session-query-tools.zh.md: 899f63fb7bf6cc6357d25cf90e077b6e2f80afa1 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md new file mode 100644 index 0000000000..27842c2799 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -0,0 +1,51 @@ +# Agent Note: Model-facing session query tools + +Status: implemented + +English | [中文](2026-07-24-model-facing-session-query-tools.zh.md) + +## Problem + +The unified `ctx.sessionQuery` service exposes exact reads, filters, relationship traces, and full-text search over live-preferred session logs, but models cannot use that service directly. Giving a model the provider request types would also expose unstable pagination cursors, trusted corpus scope, storage-shaped time values, and result records that are more convenient for programmatic consumers than for reasoning. Large traces and event payloads introduce a separate output-size concern, but solving that concern inside this consumer would duplicate the harness-wide spill mechanism and make session-query tools disagree with other tools. + +## Decision + +`@deepseek-ai/dsh-tool-session-query` is the model-facing consumer of `ctx.sessionQuery`. It registers five narrow read-only tools: `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. The package imports the interface rather than the SQLite implementation, owns model argument validation and readable text rendering, and contributes one concise prompt section that teaches the prior-history search and search-to-trace/read workflow. + +`session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. + +Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Parent ids and the root-session marker share one parent clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. + +## Workspace authority + +Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its persisted `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter, direct reads and traces authorize before loading the target, and lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. + +The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. + +## Cursor-free results and spill + +Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. + +Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. + +Session-level results include the latest folded title when available. Absence is rendered as untitled; a title read failure preserves the base result, renders an unavailable marker, and logs the underlying error. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. + +## Host composition + +The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.sessionQuery`. TUI and Web use their existing timeout and spill policies. ACP mounts the same timeout policy and private local spill backend with the shared 50,000-byte inline threshold, so the five tools have one model-facing contract across hosts. Web also mounts the SQLite query backend at its persistence root; generic tool presentation requires no session-query-specific client plugin. + +## Alternatives considered + +- **Expose provider cursors to the model** — rejected because recording a tool result or starting the next model step changes the relevant session or global generation, so a cursor is usually stale before the model can reuse it. +- **Add tool-local truncation, offsets, or spill files** — rejected because the post-execute spill policy already owns complete-result retention and retrieval across tools. +- **Allow every persisted session or model-supplied workspace filters** — rejected because `ctx.sessionQuery` is a trusted service and the model-facing consumer must enforce the caller's authority boundary. +- **Combine search, tracing, and exact reads into one operation selector** — rejected because narrow names give the model clearer schemas, defaults, presentation intents, and follow-up choices. +- **Return only one lineage hop** — rejected because spill removes the inline-size motivation while one-hop output would omit relationships with no continuation path. + +## Verification + +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while a keyless model transcript pins the prompt guidance, schemas, representative search/trace/read output, and oversized-result spill behavior. + +## Consequences + +Models gain provider-independent access to prior session work without receiving storage authority or continuation state. Search has a finite per-call work bound and may require a narrower query to reach matches beyond the first 100; complete traces and event payloads may become spill references instead of inline text. Exact string `cwd` equality favors a conservative security boundary over resolving symlink-equivalent paths. Custom compositions may mount the tool without spill, but then they explicitly accept complete inline trace and read results. diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md new file mode 100644 index 0000000000..899f63fb7b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 面向模型的会话查询工具 + +Status: implemented + +[English](2026-07-24-model-facing-session-query-tools.md) | 中文 + +## 问题 + +统一的 `ctx.sessionQuery` 服务对优先使用实时数据的会话日志提供精确读取、过滤、关系追踪与全文搜索,但模型无法直接使用该服务。若把提供方请求类型交给模型,还会暴露不稳定的分页游标、受信任的语料范围、存储形态的时间值,以及更适合程序化消费者而非模型推理的结果记录。大型追踪与事件负载另有输出大小问题,但若在该消费者内部解决,就会重复 harness 的通用 spill 机制,并使会话查询工具与其他工具的行为不一致。 + +## 决策 + +`@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。 + +`session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 + +面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。父会话 id 与根会话标记共用一个父级条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 + +## 工作区权限 + +每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标持久化的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件;直接读取与追踪在加载目标前完成授权;谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 + +搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 + +## 无游标结果与 spill + +两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。 + +追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 + +会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取失败时保留基础结果,渲染不可用标记,并记录底层错误。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 + +## 宿主组合 + +发布的 ACP、TUI 与 Web 组合都在 `ctx.sessionQuery` 旁挂载该消费者。TUI 与 Web 使用已有的超时与 spill 策略。ACP 挂载同一超时策略与私有本地 spill 后端,并采用共享的 50,000 字节行内阈值,因此五个工具在各宿主中具有同一面向模型的契约。Web 还在其持久化根目录挂载 SQLite 查询后端;通用工具表现无需会话查询专用客户端插件。 + +## 考虑过的替代方案 + +- **向模型公开提供方游标**:不予采纳,因为记录工具结果或开始下一个模型步骤会改变相关会话或全局代,导致游标通常在模型能够复用前就已过期。 +- **增加工具本地截断、偏移量或 spill 文件**:不予采纳,因为执行后 spill 策略已经统一负责各工具的完整结果保留与读取。 +- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费者必须执行调用者权限边界。 +- **把搜索、追踪与精确读取合并为一个带操作选择器的工具**:不予采纳,因为职责单一的名称能为模型提供更清晰的 schema、默认值、表现意图与后续选择。 +- **只返回一层谱系**:不予采纳,因为 spill 已消除行内大小方面的理由,而单层输出会遗漏关系且没有继续读取路径。 + +## 验证 + +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥模型 transcript 则固定提示词指导、schema、代表性搜索/追踪/读取输出和超大结果 spill 行为。 + +## 后果 + +模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。 From a350e95165cd136a946a218f829935d9775060e3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 15:09:55 +0800 Subject: [PATCH 02/19] feat(session-query): add model-facing tools (round 1) --- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/capability-seams.md | 4 +- docs/config-catalog.md | 16 + docs/module-graph.md | 9 + docs/tool-catalog.md | 234 +++++ examples/acp-agent/composition.md | 12 + examples/acp-agent/cordis.yml | 18 + examples/acp-agent/tests/acp.snapshot.ts | 7 + .../snapshots/session-query-spill/input.json | 7 + .../session-query-spill/session.jsonl | 34 + .../session-query-spill/stdout.expected.jsonl | 10 + .../text-turn/system-prompt.expected.md | 2 + .../text-turn/tool-schemas.expected.json | 204 ++++ examples/package.json | 1 + examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 6 + packages/examples/acp-demo/README.md | 2 +- packages/examples/acp-demo/package.json | 1 + .../examples/acp-demo/tests/load-path.e2e.ts | 15 +- packages/examples/tui-demo/README.md | 2 +- packages/host/runtime/package.json | 2 + packages/host/runtime/src/boot.ts | 7 +- .../host/runtime/tests/host-runtime.spec.ts | 18 + packages/host/runtime/tsconfig.json | 6 + packages/session-query/README.md | 3 +- .../tool-session-query/README.md | 72 ++ .../tool-session-query/package.json | 57 ++ .../tool-session-query/src/index.ts | 961 ++++++++++++++++++ .../tool-session-query/src/invariant.ts | 30 + .../tests/sqlite-integration.spec.ts | 100 ++ .../tests/tool-session-query.spec.ts | 796 +++++++++++++++ .../tool-session-query/tsconfig.json | 40 + .../support/acp-snapshot/src/normalize.ts | 5 + .../acp-snapshot/tests/normalize.spec.ts | 22 + pnpm-lock.yaml | 58 ++ scripts/gen-doc-graphs.ts | 4 +- scripts/gen-tool-catalog.ts | 17 + tsconfig.host.json | 1 + 40 files changed, 2781 insertions(+), 13 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/input.json create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl create mode 100644 packages/session-query/tool-session-query/README.md create mode 100644 packages/session-query/tool-session-query/package.json create mode 100644 packages/session-query/tool-session-query/src/index.ts create mode 100644 packages/session-query/tool-session-query/src/invariant.ts create mode 100644 packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts create mode 100644 packages/session-query/tool-session-query/tests/tool-session-query.spec.ts create mode 100644 packages/session-query/tool-session-query/tsconfig.json diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 20553e1b90..6e47d0b420 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -architecture.md: d1051eecf51d8d1c7f8c235b0c5dd80478b43316 -architecture.zh.md: 502c0248a9d2c62af165ce07eb19489b76c5f6ee +architecture.md: b362a4cb97aa04223805ce7f2892f9fe9b4104cf +architecture.zh.md: 3791cb619448b70005d42ea98f11869082e943fa diff --git a/docs/architecture.md b/docs/architecture.md index d1051eecf5..b362a4cb97 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts whose packages contribute serv | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | durable session-log storage | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` interface: concrete live-preferred exact/filter/trace; exactly two abstract FTS methods via `session-query-sqlite` | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | Live-preferred exact/filter/trace interface, SQLite FTS backend, and workspace-authorized model tools | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | log-backed fallbacks plus one optional asynchronous provider | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | package-name-selected registry for package-owned runtime checks | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 502c0248a9..3791cb6194 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -43,7 +43,7 @@ | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | | `ctx.sessionPersistence` | [`session-persistence/`](../packages/session-persistence/README.md) | 会话日志的持久化存储 | -| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | `session-query` 接口:精确检索、过滤与追踪采用实时优先的具体实现;恰有两个全文搜索方法为抽象方法,由 `session-query-sqlite` 实现 | +| `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 实时优先的精确检索/过滤/追踪接口、SQLite 全文搜索后端,以及经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session-title/`](../packages/session-title/README.md) | 基于日志的回退标题,以及单个可选的异步提供方 | | `ctx.invariants` | [`support/invariants`](../packages/support/invariants/README.md) | 按包名筛选包自有运行时检查的注册表 | diff --git a/docs/capability-seams.md b/docs/capability-seams.md index a27d325e31..4806a86e72 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -38,6 +38,7 @@ flowchart LR pkg_acp["acp"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] + pkg_tool_session_query["tool-session-query"] svc_sessionReferences["ctx.sessionReferences
Cross-session snapshot preparation"] pkg_tui["tui"] pkg_session_title["session-title"] @@ -223,6 +224,7 @@ flowchart LR svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash svc_sessionQuery --> pkg_session_reference + svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_acp svc_sessionReferences --> pkg_tui svc_sessions --> pkg_agent @@ -276,7 +278,7 @@ flowchart LR | `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | -| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. | +| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | | `ctx.systemPrompt` | `core` | [`system-prompt`](../packages/core/system-prompt) | - | [`agent-loop`](../packages/core/agent-loop), [`tools`](../packages/core/tools), [`tool-fs`](../packages/fs/tool-fs), [`tool-pty`](../packages/pty/tool-pty), [`tool-web`](../packages/web/tool-web) | - | Collects prompt sections and model-facing tool schemas for each step. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 985e493184..d76443579a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1423,6 +1423,22 @@ export interface Config { Source: [`packages/workflow/tool-ralph/src/index.ts:23`](../packages/workflow/tool-ralph/src/index.ts) +## `@deepseek-ai/dsh-tool-session-query` + +Requires: `tools` · `systemPrompt` · `sessionQuery` + +```ts config-catalog +/** Deployment-owned search count and timeout bounds. */ +export interface Config { + /** Maximum authorized hits returned by one search call. Defaults to 100. */ + maxSearchResults?: number + /** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */ + searchTimeoutMs?: number +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts:50`](../packages/session-query/tool-session-query/src/index.ts) + ## `@deepseek-ai/dsh-tool-skill` Requires: `tools` · `skills` diff --git a/docs/module-graph.md b/docs/module-graph.md index 7e2ee76934..5600279890 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -106,6 +106,7 @@ flowchart TD subgraph group_session_query["packages/session-query"] pkg_session_query["session-query"] pkg_session_query_sqlite["session-query-sqlite"] + pkg_tool_session_query["tool-session-query"] end subgraph group_session_title["packages/session-title"] pkg_session_title["session-title"] @@ -570,6 +571,13 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_session pkg_session_checkpoint_policy --> pkg_session_persistence pkg_session_checkpoint_policy --> pkg_tools + pkg_tool_session_query --> pkg_invariants + pkg_tool_session_query --> pkg_llm + pkg_tool_session_query --> pkg_session + pkg_tool_session_query --> pkg_session_query + pkg_tool_session_query --> pkg_system_prompt + pkg_tool_session_query --> pkg_timeout + pkg_tool_session_query --> pkg_tools pkg_agent_loop_testkit --> pkg_agent pkg_agent_loop_testkit --> pkg_invariants pkg_agent_loop_testkit --> pkg_llm @@ -883,6 +891,7 @@ flowchart TD | [`tool-cordis`](../packages/cordis/tool-cordis) | `cordis` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy) | `session-persistence` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools) | +| [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 4f9de3644f..10f7fc74d9 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -27,6 +27,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | @@ -778,6 +779,239 @@ Load the full instructions for an available skill. Call this with the exact skil Source: [`packages/skill/tool-skill/src/index.ts`](../packages/skill/tool-skill/src/index.ts) +## `@deepseek-ai/dsh-tool-session-query` + +### `session_event_read` + +Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_event_search` + +Search prior events in one authorized session; the current session excludes the step performing this call. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_event_trace` + +Read every direct replacement and provenance relationship for one event in an authorized session. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_search` + +Search prior sessions in the caller workspace and return the strongest matching event from each session. + +```json +{ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +### `session_trace` + +Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. + +```json +{ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } +} +``` + +Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) + +The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy. + ## `@deepseek-ai/dsh-tool-subagent` ### `subagent` diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index 841639fd90..b81676f382 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -29,6 +29,14 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_acp_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] + cfg --> plugin_acp_tool_session_query + plugin_acp_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_acp_timeout_policy + plugin_acp_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_acp_spill_local + plugin_acp_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_acp_spill_policy plugin_acp_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] cfg --> plugin_acp_plan_mode plugin_acp_tool_ask_user["tool-ask-user
@deepseek-ai/dsh-tool-ask-user"] @@ -78,6 +86,10 @@ flowchart LR | `approval` | `@deepseek-ai/dsh-user-approval` | | `permission` | `@deepseek-ai/dsh-permission` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | +| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | | `plan-mode` | `@deepseek-ai/dsh-plan-mode` | | `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index d0f9b43367..3e7235c35c 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -65,6 +65,24 @@ Verify your work by running the code or tests. Keep answers brief and factual. +# Workspace-authorized prior-session search and exact trace/read tools. The app +# above owns ctx.sessionQuery; this leaf owns the model-facing consumer. +- id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + +# Enforce declared search deadlines and spill oversized plain-text tool output +# without introducing a session-query-specific truncation path. +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + # Plan mode is additive to the canonical ACP server. The ACP bridge projects # it onto the protocol picker; sandbox and approval remain independent options. - id: plan-mode diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index e35d47cea0..a19f863de6 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -100,6 +100,13 @@ const SCENARIOS: Scenario[] = [ configPath: FS_CONFIG, }, { name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG }, + { + name: 'session-query-spill', + hasModelTurn: true, + recorded: false, + configPath: FS_CONFIG, + posixOnly: true, + }, { name: 'pty-tools', hasModelTurn: true, diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/input.json b/examples/acp-agent/tests/snapshots/session-query-spill/input.json new file mode 100644 index 0000000000..0e0abb74a2 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl new file mode 100644 index 0000000000..c4b4f54047 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -0,0 +1,34 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Read request event 4 with session_event_read, verify the complete spill was retained, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":0,"data":{"title":"Read request event 4 with","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_session_query_spill","name":"session_event_read","argumentsDelta":"{\"seq\":4}"}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} +{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} +{"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","content":[{"type":"text","text":"SPILL_CANONICAL_OK\n"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":30,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"step/end","seq":31,"time":0,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":32,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl new file mode 100644 index 0000000000..a1a65b452c --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -0,0 +1,10 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"sessionCapabilities":{"list":{}},"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","modes":{"availableModes":[{"id":"default","name":"default"},{"id":"plan","name":"plan"}],"currentModeId":"default"},"configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read request event 4 with","updatedAt":"{{updatedAt}}"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"Read event 4","kind":"read","status":"in_progress","rawInput":{"seq":4}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_CANONICAL_OK\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 17e6773a03..68bdd841c7 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index b01e7683d1..02237f770b 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/package.json b/examples/package.json index 1b4e28c4fd..9e9534379d 100644 --- a/examples/package.json +++ b/examples/package.json @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-tool-goal": "workspace:*", "@deepseek-ai/dsh-tool-lsp": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", + "@deepseek-ai/dsh-tool-session-query": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index fd6d163952..557923b733 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -23,6 +23,8 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_tui_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] + cfg --> plugin_tui_tool_session_query plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] @@ -71,6 +73,7 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 3070fcdc01..479ad24d09 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -7,6 +7,7 @@ - id: hmr name: '@cordisjs/plugin-hmr' + disabled: !!js process.env.CI === 'true' config: root: ['.'] @@ -52,6 +53,11 @@ Verify your work by running the code or tests. Keep answers brief and factual. +# The app above owns ctx.sessionQuery; expose its workspace-authorized +# prior-session search and exact trace/read operations to the model. +- id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + # Model-made session titles on the first-message cadence: replaces the spine's # deterministic fallback title with a short model summary. The TUI renders the # logged `session/title` as the banner subtitle and the terminal window title. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 93d89d6557..bd8c980993 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -15,7 +15,7 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it | `@deepseek-ai/dsh-command-goal` | the discoverable direct `/goal` producer; the app enables the spine's persisted-goal stack with it | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | combined exact/FTS session queries and bounded `dsh-session:` snapshots; the default leaf adds the model-facing query tools | | `@deepseek-ai/dsh-session-checkpoint-policy` | semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | | `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool | | ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately | diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 36de692404..ad1ece0095 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -70,6 +70,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 982fac3b50..a866aaa243 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -29,8 +29,9 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // Repo root is four levels up from packages/examples/acp-demo/tests. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -// A minimal leaf that loads this app + the two backends — the same shape as -// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture. +// A minimal leaf that loads this app + the two backends and the shipped +// session-query consumer/policies — the same shape as examples/acp-agent/cordis.yml, +// inlined so the package test owns its fixture. const CORDIS_YML = ` - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' @@ -45,6 +46,16 @@ const CORDIS_YML = ` model: deepseek-v4-flash persona: 'You are a test agent.' workspaceContext: false +- id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 ` interface Spawned { diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index a20a0fae9d..5bcbed615c 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and | `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | | `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | | `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; the default leaf adds the model-facing query tools | | `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | | `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | | `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index 94a544fb74..f6d43afae0 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -41,6 +41,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index c0960ab678..353f74d0b6 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -5,6 +5,7 @@ */ import { Context } from 'cordis' +import { join } from 'node:path' import Timer from '@cordisjs/plugin-timer' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' @@ -18,6 +19,8 @@ import TaskService from '@deepseek-ai/dsh-tasks' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' +import * as toolSessionQuery from '@deepseek-ai/dsh-tool-session-query' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as toolTodo from '@deepseek-ai/dsh-tool-todo' @@ -61,7 +64,7 @@ const DEFAULT_SESSION_TITLE_LLM_CONFIG: SessionTitleLlmConfig = { /** Options for bootHost — the assembly-layer composition knobs. */ export interface BootHostOptions { - /** Root directory for JSONL session persistence. */ + /** Root for JSONL persistence and parent directory of the derived session-query SQLite index. */ persistenceRoot: string /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */ workspaceContext: workspaceContext.Config | false @@ -131,6 +134,8 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LlmDeepSeek, {}) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) + await ctx.plugin(SessionQuerySqlite, { path: join(options.persistenceRoot, 'session-query.db') }) + await ctx.plugin(toolSessionQuery, {}) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + // the agent-spine bundle) so web sessions get the same coding-agent tool diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index c30e07b50b..1b58a6511b 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -141,6 +141,24 @@ describe('bootHost / startHost', () => { await handle.dispose() }) + it('assembles workspace-authorized session query tools over the derived SQLite index', async () => { + const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-boot-session-query-')) + const handle = await bootHost({ + persistenceRoot, + workspaceContext: false, + }) + expect(handle.ctx.get('sessionQuery')).toBeDefined() + expect(handle.ctx.tools.schemas().map(schema => schema.name)).toEqual(expect.arrayContaining([ + 'session_search', + 'session_event_search', + 'session_trace', + 'session_event_trace', + 'session_event_read', + ])) + expect(handle.ctx.tools.get('session_search')?.timeoutMs).toBe(30_000) + await handle.dispose() + }) + it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => { const running = await boot() expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' }) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index aee28b5371..b0cef9eb16 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -47,6 +47,12 @@ { "path": "../../session-persistence/session-persistence-jsonl" }, + { + "path": "../../session-query/session-query-sqlite" + }, + { + "path": "../../session-query/tool-session-query" + }, { "path": "../../bash/bash-local" }, diff --git a/packages/session-query/README.md b/packages/session-query/README.md index f79d35936c..503a9cfed8 100644 --- a/packages/session-query/README.md +++ b/packages/session-query/README.md @@ -6,5 +6,6 @@ Trusted exact reads, relationship traces, provider-independent semantic filterin |---|---|---| | [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` | | [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` | +| [`tool-session-query/`](tool-session-query/README.md) | Workspace-authorized model-facing search, lineage, relationship, and exact event tools | — | -The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, and one concrete backend owns the full-text lifecycle without a provider registry or coordinator. +The query service is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, one concrete backend owns the full-text lifecycle without a provider registry or coordinator, and the consumer leaves oversized plain-text results to the generic post-execute spill policy. diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md new file mode 100644 index 0000000000..2a5ad72f9c --- /dev/null +++ b/packages/session-query/tool-session-query/README.md @@ -0,0 +1,72 @@ +# @deepseek-ai/dsh-tool-session-query + +Workspace-authorized model tools over `ctx.sessionQuery`. The package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. + +## Configuration + +| Key | Default | Meaning | +|---|---:|---| +| `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages | +| `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools | + +The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. + +`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. + +The package deliberately performs no byte or character truncation and does not import a spill backend. Deployments that need bounded inline output mount `@deepseek-ai/dsh-spill-policy`, which can replace the rendered text after execution while retaining the complete result. + +## Model Experience + +### System prompt + +#### What the model sees + +The model receives one fixed prior-history guidance section. + +##### Prior-history guidance + +```markdown +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. +``` + +#### Token effect + +One fixed concise section is present on each request while the plugin is mounted. + +#### KV Cache effect + +Prefix-stable while the plugin and guidance text are unchanged. + +### Tool schemas + +#### What the model sees + +The model sees the generated [`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query). Search filters add fixed schema tokens, while cursors, workspace paths, output pagination, and model-controlled result limits remain absent. + +#### Token effect + +Five fixed read-only schemas are sent on each request while visible. + +#### KV Cache effect + +Prefix-stable while tool visibility and definitions are unchanged. + +### Tool results + +#### What the model sees + +Each successful call emits one plain-text block. Search results include titles and best-match excerpts; traces include all authorized relationships; event reads include unabridged target JSON. The generic spill policy may replace oversized inline text with its preview, opaque locator, and retrieval hint. + +#### Token effect + +Results are data-dependent and remain in logged tool history until compaction; `maxSearchResults` bounds search-hit count. + +#### KV Cache effect + +Append-only result text follows the reusable request prefix and does not invalidate earlier cache entries. + +## Known Limitations and Deferred Work + +- Search returns at most the deployment cap and asks the model to narrow its query when more matches exist; it offers no continuation token. +- Workspace identity is conservative exact-string `cwd` equality, so symlink-equivalent paths do not share authority. +- Custom compositions without the generic spill policy accept complete trace and event payloads inline. diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json new file mode 100644 index 0000000000..9438ddb1de --- /dev/null +++ b/packages/session-query/tool-session-query/package.json @@ -0,0 +1,57 @@ +{ + "name": "@deepseek-ai/dsh-tool-session-query", + "description": "Workspace-authorized model-facing session history search, trace, and event read tools", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-query": "^0.0.1", + "@deepseek-ai/dsh-system-prompt": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts new file mode 100644 index 0000000000..c8c7350604 --- /dev/null +++ b/packages/session-query/tool-session-query/src/index.ts @@ -0,0 +1,961 @@ +/** + * Model-facing, workspace-authorized session-history search and read tools. + * + * @module @deepseek-ai/dsh-tool-session-query + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { + SessionId, + type SessionEvent, + type SessionEventType, + type SessionHeader, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import { + SessionQueryError, + extractSessionEventText, + type SessionAvailability, + type SessionEventMetadataFilter, + type SessionEventSearchHit, + type SessionEventSurface, + type SessionEventTrace, + type SessionEventWindow, + type SessionLineageNode, + type SessionLineageTrace, + type SessionRecord, + type SessionResultFilter, + type SessionSearchCursor, + type SessionSearchHit, +} from '@deepseek-ai/dsh-session-query' +import { defineTool, type GenericCallView, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-system-prompt' + +/** Cordis plugin name used by Loader diagnostics. */ +export const name = 'tool-session-query' + +/** Capability services required by the model-facing consumer. */ +export const inject = ['tools', 'systemPrompt', 'sessionQuery'] + +/** Default maximum number of authorized search hits returned by one call. */ +export const DEFAULT_MAX_SEARCH_RESULTS = 100 + +/** Default cooperative deadline for either full-text search tool. */ +export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000 + +/** Deployment-owned search count and timeout bounds. */ +export interface Config { + /** Maximum authorized hits returned by one search call. Defaults to 100. */ + maxSearchResults?: number + /** Cooperative full-text search deadline in milliseconds. Defaults to 30000. */ + searchTimeoutMs?: number +} + +/** Schemastery config for Loader defaults and generated configuration docs. */ +export const Config: z = z.object({ + maxSearchResults: z.number().step(1).min(1).default(DEFAULT_MAX_SEARCH_RESULTS), + searchTimeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).default(DEFAULT_SEARCH_TIMEOUT_MS), +}) + +interface ResolvedConfig { + readonly maxSearchResults: number + readonly searchTimeoutMs: number +} + +interface SessionSearchArgs { + query: string + session_ids?: string[] + created_at_from?: string + created_at_to?: string + parent_session_ids?: string[] + include_root_sessions?: boolean + availability?: SessionAvailability[] + event_seq_from?: number + event_seq_to?: number + event_time_from?: string + event_time_to?: string + event_types?: string[] + event_surfaces?: SessionEventSurface[] +} + +interface EventSearchArgs { + session_id?: string + query: string + seq_from?: number + seq_to?: number + time_from?: string + time_to?: string + event_types?: string[] + surfaces?: SessionEventSurface[] +} + +interface SessionTargetArgs { + session_id?: string +} + +interface EventTargetArgs extends SessionTargetArgs { + seq: number +} + +interface EventReadArgs extends EventTargetArgs { + before?: number + after?: number +} + +interface Caller { + readonly id: SessionIdValue + readonly header: SessionHeader + readonly events: readonly SessionEvent[] +} + +interface TitleView { + readonly text: string + readonly unavailableCode?: string +} + +interface CompleteTitleMap extends ReadonlyMap { + get(id: SessionIdValue): TitleView +} + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +interface AuthorizedDescendant { + readonly record: SessionRecord + readonly descendants: Array +} + +const SESSION_SEARCH_PARAMETERS = { + query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, + session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, + created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' }, + created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' }, + parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' }, + include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' }, + availability: { + type: 'array', + items: { type: 'string', enum: ['live', 'persisted'] }, + description: 'Require at least one selected source availability.', + }, + event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + event_surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const EVENT_SEARCH_PARAMETERS = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, + query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' }, + seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const TARGET_SESSION_PARAMETER = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, +} as const + +const TEXT_OUTPUT = { + schema: { type: 'string' as const }, + render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }], +} + +const PROMPT_TEXT = + 'Use session_search to find relevant work from prior sessions, or session_event_search to search earlier ' + + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' + +/** Register all five tools and their shared model guidance. */ +export function apply(ctx: Context, config: Config): void { + const resolved = resolveConfig(config) + ctx.systemPrompt.section({ + name: 'tool:session-query', + order: 113, + text: PROMPT_TEXT, + }) + + ctx.tools.register(defineTool({ + name: 'session_search', + description: 'Search prior sessions in the caller workspace and return the strongest matching event from each session.', + parameters: SESSION_SEARCH_PARAMETERS, + output: TEXT_OUTPUT, + timeoutMs: resolved.searchTimeoutMs, + isConcurrencySafe: () => true, + execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentSessionSearchCall, + })) + + ctx.tools.register(defineTool({ + name: 'session_event_search', + description: 'Search prior events in one authorized session; the current session excludes the step performing this call.', + parameters: EVENT_SEARCH_PARAMETERS, + output: TEXT_OUTPUT, + timeoutMs: resolved.searchTimeoutMs, + isConcurrencySafe: () => true, + execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentEventSearchCall, + })) + + ctx.tools.register(defineTool({ + name: 'session_trace', + description: 'Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.', + parameters: TARGET_SESSION_PARAMETER, + output: TEXT_OUTPUT, + isConcurrencySafe: () => true, + execute: (args, exec) => executeSessionTrace(ctx, args, exec), + presentCall: presentSessionTraceCall, + })) + + ctx.tools.register(defineTool({ + name: 'session_event_trace', + description: 'Read every direct replacement and provenance relationship for one event in an authorized session.', + parameters: { + ...TARGET_SESSION_PARAMETER, + seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, + }, + output: TEXT_OUTPUT, + isConcurrencySafe: () => true, + execute: (args, exec) => executeEventTrace(ctx, args, exec), + presentCall: args => presentEventTargetCall('Trace event', args), + })) + + ctx.tools.register(defineTool({ + name: 'session_event_read', + description: 'Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.', + parameters: { + ...TARGET_SESSION_PARAMETER, + seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, + before: { type: 'integer', description: 'Number of preceding raw events to summarize. Omit for none.' }, + after: { type: 'integer', description: 'Number of following raw events to summarize. Omit for none.' }, + }, + output: TEXT_OUTPUT, + isConcurrencySafe: () => true, + execute: (args, exec) => executeEventRead(ctx, args, exec), + presentCall: args => presentEventTargetCall('Read event', args), + })) +} + +function resolveConfig(config: Config): ResolvedConfig { + const maxSearchResults = config.maxSearchResults ?? DEFAULT_MAX_SEARCH_RESULTS + const searchTimeoutMs = config.searchTimeoutMs ?? DEFAULT_SEARCH_TIMEOUT_MS + if (!Number.isSafeInteger(maxSearchResults) || maxSearchResults < 1) { + throw new TypeError('tool-session-query: maxSearchResults must be a positive safe integer') + } + if (!Number.isInteger(searchTimeoutMs) || searchTimeoutMs < 1 || searchTimeoutMs > MAX_TIMER_DELAY_MS) { + throw new TypeError( + `tool-session-query: searchTimeoutMs must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`, + ) + } + return { maxSearchResults, searchTimeoutMs } +} + +function callerOf(exec: ToolRunContext): Caller { + const agent = exec.agent + if (agent === undefined) { + throw new HarnessError( + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', + ) + } + return { + id: agent.session.id, + header: agent.session.header, + events: agent.session.events, + } +} + +function targetId(args: SessionTargetArgs, caller: Caller): SessionIdValue { + return args.session_id === undefined ? caller.id : SessionId(args.session_id) +} + +async function authorizeTarget( + ctx: Context, + caller: Caller, + target: SessionIdValue, + signal: AbortSignal, +): Promise { + if (target === caller.id) return + const cwd = caller.header.cwd + if (cwd === undefined) throw unauthorizedTarget() + signal.throwIfAborted() + const records = await ctx.sessionQuery.filterSessions([ + { kind: 'id', values: [target] }, + { kind: 'cwd', values: [cwd] }, + ]) + signal.throwIfAborted() + if (records.length !== 1) throw unauthorizedTarget() +} + +function unauthorizedTarget(): HarnessError { + return new HarnessError( + 'session target is outside the caller workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) +} + +async function executeSessionSearch( + ctx: Context, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = callerOf(exec) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + } + const query = normalizeQuery(args.query) + const sessionFilters = buildSessionFilters(args) + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const eventFilters = buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal }), + hit => hit.header.id !== caller.id && recordAuthorized(hit, caller), + ) + + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionIdValue => id !== undefined) + const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await readTitles(ctx, collected.items.map(hit => hit.header.id), exec.signal) + return formatSessionSearch(collected, titles, authorizedParents) +} + +async function executeEventSearch( + ctx: Context, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = callerOf(exec) + const sessionId = targetId(args, caller) + await authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = normalizeQuery(args.query) + const range = sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStart = caller.events.findLast(event => event.type === 'step/start') + if (stepStart === undefined) { + throw new HarnessError( + 'current-session search requires an active step boundary', + 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', + ) + } + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) + } + const title = await readTitle(ctx, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal }), + () => true, + ) + return formatEventSearch(sessionId, title, collected) +} + +async function executeSessionTrace( + ctx: Context, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = callerOf(exec) + const sessionId = targetId(args, caller) + await authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await ctx.sessionQuery.traceSession(sessionId) + exec.signal.throwIfAborted() + + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break + } + ancestors.push(ancestor) + } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...descendantIds(descendants), + ] + const titles = await readTitles(ctx, visibleIds, exec.signal) + return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) +} + +async function executeEventTrace( + ctx: Context, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + assertNonNegativeSafeInteger('seq', args.seq) + const caller = callerOf(exec) + const sessionId = targetId(args, caller) + await authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }) + exec.signal.throwIfAborted() + const title = await readTitle(ctx, sessionId, exec.signal) + return formatEventTrace(sessionId, title, trace) +} + +async function executeEventRead( + ctx: Context, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + assertNonNegativeSafeInteger('seq', args.seq) + if (args.before !== undefined) assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) assertNonNegativeSafeInteger('after', args.after) + const caller = callerOf(exec) + const sessionId = targetId(args, caller) + await authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await ctx.sessionQuery.readEvent({ + sessionId, + seq: args.seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }) + exec.signal.throwIfAborted() + const title = await readTitle(ctx, sessionId, exec.signal) + return formatEventRead(sessionId, title, window) +} + +function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { + const filters: SessionResultFilter[] = [] + if (args.session_ids !== undefined) { + assertNonEmptyArray('session_ids', args.session_ids) + filters.push({ kind: 'id', values: args.session_ids.map(SessionId) }) + } + const created = timestampRange('created_at', args.created_at_from, args.created_at_to) + if (created !== undefined) filters.push({ kind: 'created-at', ...created }) + if (args.parent_session_ids !== undefined || args.include_root_sessions === true) { + const values: Array = [] + if (args.parent_session_ids !== undefined) { + assertNonEmptyArray('parent_session_ids', args.parent_session_ids) + values.push(...args.parent_session_ids.map(SessionId)) + } + if (args.include_root_sessions === true) values.push(null) + filters.push({ kind: 'parent', values }) + } + if (args.availability !== undefined) { + assertNonEmptyArray('availability', args.availability) + filters.push({ kind: 'availability', values: args.availability }) + } + return filters +} + +interface EventFilterInput { + readonly seqFrom?: number | undefined + readonly seqTo?: number | undefined + readonly timeFrom?: string | undefined + readonly timeTo?: string | undefined + readonly eventTypes?: string[] | undefined + readonly surfaces?: SessionEventSurface[] | undefined +} + +function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] { + const filters: SessionEventMetadataFilter[] = [] + const seq = sequenceRange(input.seqFrom, input.seqTo) + if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq }) + const time = timestampRange('time', input.timeFrom, input.timeTo) + if (time !== undefined) filters.push({ kind: 'time', ...time }) + if (input.eventTypes !== undefined) { + assertNonEmptyArray('event_types', input.eventTypes) + filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] }) + } + if (input.surfaces !== undefined) { + assertNonEmptyArray('surfaces', input.surfaces) + filters.push({ kind: 'surface', values: input.surfaces }) + } + return filters +} + +function normalizeQuery(value: string): string { + const query = value.trim().replace(/\s+/gu, ' ') + if (query.length === 0) { + throw new SessionQueryError( + 'session-search query must contain non-whitespace text', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + if (query.includes('\0')) { + throw new SessionQueryError( + 'session-search query must not contain NUL', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return query +} + +function sequenceRange( + from: number | undefined, + to: number | undefined, +): { from?: number; to?: number } { + if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from) + if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to) + if (from !== undefined && to !== undefined && from > to) { + throw invalidRange('sequence', 'from must be less than or equal to to') + } + return { + ...from === undefined ? {} : { from }, + ...to === undefined ? {} : { to }, + } +} + +function timestampRange( + name: string, + from: string | undefined, + to: string | undefined, +): { from?: number; to?: number } | undefined { + if (from === undefined && to === undefined) return undefined + const fromMs = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) + const toMs = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) + if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) { + throw invalidRange(name, 'from must be less than or equal to to') + } + return { + ...fromMs === undefined ? {} : { from: fromMs }, + ...toMs === undefined ? {} : { to: toMs }, + } +} + +const ISO_TIMESTAMP = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ + +function parseIsoTimestamp(name: string, value: string): number { + const match = ISO_TIMESTAMP.exec(value) + if (match === null) { + throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') + } + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4]) + const minute = Number(match[5]) + const second = Number(match[6] ?? 0) + const offsetHour = Number(match[10] ?? 0) + const offsetMinute = Number(match[11] ?? 0) + if ( + month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month) + || hour > 23 || minute > 59 || second > 59 + || offsetHour > 23 || offsetMinute > 59 + ) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + const timestamp = Date.parse(value) + return timestamp +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + +function invalidRange(name: string, detail: string): SessionQueryError { + return new SessionQueryError( + `session ${name} range ${detail}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} + +function assertNonNegativeSafeInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new SessionQueryError( + `${name} must be a non-negative safe integer`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +function assertNonEmptyArray(name: string, values: readonly unknown[]): void { + if (values.length === 0) { + throw new SessionQueryError( + `${name} must contain at least one value when supplied`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +async function collectPages( + maxResults: number, + signal: AbortSignal, + request: (cursor?: SessionSearchCursor) => Promise<{ + readonly items: readonly T[] + readonly nextCursor?: SessionSearchCursor + }>, + accept: (item: T) => boolean, +): Promise> { + const items: T[] = [] + const seen = new Set() + let cursor: SessionSearchCursor | undefined + while (true) { + signal.throwIfAborted() + let page: Awaited> + try { + page = await request(cursor) + } catch (error: unknown) { + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_STALE_CURSOR') { + throw new SessionQueryError( + 'session history changed while paging; retry the complete search call', + 'SESSION_QUERY_STALE_CURSOR', + { cause: error }, + ) + } + throw error + } + signal.throwIfAborted() + for (const item of page.items) { + if (!accept(item)) continue + items.push(item) + if (items.length === maxResults) { + return { + items, + capped: page.nextCursor !== undefined || item !== page.items.at(-1), + } + } + } + if (page.nextCursor === undefined) return { items, capped: false } + if (seen.has(page.nextCursor)) { + throw new SessionQueryError( + 'session-search provider repeated a continuation cursor', + 'SESSION_QUERY_INVALID_CURSOR', + ) + } + seen.add(page.nextCursor) + cursor = page.nextCursor + } +} + +function recordAuthorized(record: SessionRecord, caller: Caller): boolean { + if (record.header.id === caller.id) return true + return caller.header.cwd !== undefined && record.header.cwd === caller.header.cwd +} + +async function authorizeSessionIds( + ctx: Context, + caller: Caller, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise> { + const unique = [...new Set(ids)] + const authorized = new Set() + if (unique.includes(caller.id)) authorized.add(caller.id) + const cwd = caller.header.cwd + const other = unique.filter(id => id !== caller.id) + if (cwd === undefined || other.length === 0) return authorized + signal.throwIfAborted() + const records = await ctx.sessionQuery.filterSessions([ + { kind: 'id', values: other }, + { kind: 'cwd', values: [cwd] }, + ]) + signal.throwIfAborted() + for (const record of records) authorized.add(record.header.id) + return authorized +} + +async function readTitles( + ctx: Context, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise { + const result = new Map() + for (const id of new Set(ids)) { + result.set(id, await readTitle(ctx, id, signal)) + } + return result as CompleteTitleMap +} + +async function readTitle( + ctx: Context, + id: SessionIdValue, + signal: AbortSignal, +): Promise { + signal.throwIfAborted() + try { + const title = await ctx.sessionQuery.readTitle(id) + signal.throwIfAborted() + return { text: title?.title ?? 'untitled' } + } catch (error: unknown) { + if (signal.aborted) signal.throwIfAborted() + const code = error instanceof HarnessError ? error.code : 'UNKNOWN' + ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) + return { text: 'untitled', unavailableCode: code } + } +} + +function fullError(error: unknown): string { + return error instanceof Error ? error.stack ?? String(error) : String(error) +} + +function authorizeDescendants( + nodes: readonly SessionLineageNode[], + caller: Caller, +): Array { + return nodes.map((node) => { + if (!recordAuthorized(node.session, caller)) return null + return { + record: node.session, + descendants: authorizeDescendants(node.descendants, caller), + } + }) +} + +function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { + const ids: SessionIdValue[] = [] + for (const node of nodes) { + if (node === null) continue + ids.push(node.record.header.id, ...descendantIds(node.descendants)) + } + return ids +} + +function titleText(view: TitleView): string { + return view.unavailableCode === undefined + ? view.text + : `${view.text} (title unavailable: ${view.unavailableCode})` +} + +function formatSessionSearch( + collected: SearchCollection, + titles: CompleteTitleMap, + authorizedParents: ReadonlySet, +): string { + if (collected.items.length === 0) return 'No prior session matches found.' + const lines = [`Session search results (${collected.items.length}):`] + for (const [index, hit] of collected.items.entries()) { + const parent = hit.header.parentSession === undefined + ? 'root' + : authorizedParents.has(hit.header.parentSession) + ? hit.header.parentSession + : '[outside workspace]' + const availability = [ + hit.live ? 'live' : undefined, + hit.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' + lines.push( + '', + `${index + 1}. Session ${hit.header.id} — ${titleText(titles.get(hit.header.id))}`, + ` Created: ${formatTime(hit.header.createdAt)}`, + ` Parent: ${parent}`, + ` Availability: ${availability}`, + ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`, + ` Snippet: ${hit.bestMatch.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatEventSearch( + sessionId: SessionIdValue, + title: TitleView, + collected: SearchCollection, +): string { + const lines = [`Session ${sessionId} — ${titleText(title)}`] + if (collected.items.length === 0) { + lines.push('', 'No prior event matches found.') + return lines.join('\n') + } + lines.push('', `Event search results (${collected.items.length}):`) + for (const [index, hit] of collected.items.entries()) { + lines.push( + `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`, + ` Snippet: ${hit.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatSessionTrace( + trace: SessionLineageTrace, + ancestors: readonly SessionRecord[], + ancestorBoundary: boolean, + descendants: readonly (AuthorizedDescendant | null)[], + titles: CompleteTitleMap, +): string { + const lines = [ + `Session ${trace.target.header.id} — ${titleText(titles.get(trace.target.header.id))}`, + `Created: ${formatTime(trace.target.header.createdAt)}`, + `Availability: ${availabilityText(trace.target)}`, + '', + 'Ancestors (nearest first):', + ] + if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)') + for (const record of ancestors) { + lines.push(`- ${record.header.id} — ${titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`) + } + if (ancestorBoundary) lines.push('- [outside workspace boundary]') + lines.push('', 'Descendants:') + if (descendants.length === 0) lines.push('- none') + else renderDescendants(lines, descendants, titles, 0) + return lines.join('\n') +} + +function renderDescendants( + lines: string[], + nodes: readonly (AuthorizedDescendant | null)[], + titles: CompleteTitleMap, + depth: number, +): void { + for (const node of nodes) { + const indent = ' '.repeat(depth) + if (node === null) { + lines.push(`${indent}- [outside workspace subtree]`) + continue + } + const id = node.record.header.id + lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) + renderDescendants(lines, node.descendants, titles, depth + 1) + } +} + +function formatEventTrace( + sessionId: SessionIdValue, + title: TitleView, + trace: SessionEventTrace, +): string { + return [ + `Session ${sessionId} — ${titleText(title)}`, + `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`, + `Replaced by: ${trace.replacedBy ?? 'none'}`, + `Replacement chain: ${seqList(trace.replacementChain)}`, + `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`, + `Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`, + `Direct derived events: ${seqList(trace.derivedEventSeqs)}`, + ].join('\n') +} + +function formatEventRead( + sessionId: SessionIdValue, + title: TitleView, + window: SessionEventWindow, +): string { + const before = window.events.filter(event => event.seq < window.target.seq) + const after = window.events.filter(event => event.seq > window.target.seq) + const lines = [ + `Session ${sessionId} — ${titleText(title)}`, + `Target event seq ${window.target.seq}:`, + '```json', + JSON.stringify(window.target, null, 2), + '```', + ] + if (before.length > 0) { + lines.push('', 'Before:') + for (const event of before) lines.push(formatNeighbor(event)) + } + if (after.length > 0) { + lines.push('', 'After:') + for (const event of after) lines.push(formatNeighbor(event)) + } + return lines.join('\n') +} + +function formatNeighbor(event: SessionEvent): string { + const text = extractSessionEventText(event) + return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}` + + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`) +} + +function availabilityText(record: SessionRecord): string { + return [ + record.live ? 'live' : undefined, + record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' +} + +function seqList(values: readonly number[]): string { + return values.length === 0 ? 'none' : values.join(', ') +} + +function formatTime(value: number): string { + return new Date(value).toISOString() +} + +function presentSessionSearchCall(args: SessionSearchArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query } +} + +function presentEventSearchCall(args: EventSearchArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query } +} + +function presentSessionTraceCall(args: SessionTargetArgs): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`, + ...args.session_id === undefined ? {} : { rawInput: args.session_id }, + } +} + +function presentEventTargetCall( + action: string, + args: EventTargetArgs, +): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: `${action} ${args.seq}`, + rawInput: { + ...args.session_id === undefined ? {} : { session_id: args.session_id }, + seq: args.seq, + }, + } +} diff --git a/packages/session-query/tool-session-query/src/invariant.ts b/packages/session-query/tool-session-query/src/invariant.ts new file mode 100644 index 0000000000..73f0e35409 --- /dev/null +++ b/packages/session-query/tool-session-query/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tool-session-query`. + * @module @deepseek-ai/dsh-tool-session-query/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tool-session-query' + +/** Cordis companion plugin name. */ +export const name = 'tool-session-query-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this read-only model adapter owns no event or mutable + * data relationship beyond the registries that already validate registration. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts new file mode 100644 index 0000000000..fb85bdc309 --- /dev/null +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type Session, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' + +const temporaryDirectories: string[] = [] +const contexts: Context[] = [] + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const directory of temporaryDirectories.splice(0)) { + await rm(directory, { recursive: true, force: true }) + } +}) + +function fakeAgent(session: Session): Agent { + return { id: session.id, session } as unknown as Agent +} + +describe('tool-session-query with the real SQLite provider', () => { + it('searches live prior-step history and a persisted same-workspace log', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-')) + temporaryDirectories.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + await ctx.plugin(SessionQuerySqlite, { path: join(root, 'session-query.db') }) + await ctx.plugin(ToolSessionQuery) + + const persisted = SessionId('persisted') + await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: persisted, + createdAt: 1, + cwd: '/work', + }) + await ctx.sessionPersistence.append(persisted, [{ + type: 'user/message', + seq: 0, + time: 2, + data: { + content: [{ type: 'text', text: 'persisted integration needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }]) + + const caller = ctx.sessions.create(SessionId('caller'), { + meta: { createdAt: 10, cwd: '/work' }, + }) + caller.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + caller.append( + 'user/message', + { content: [{ type: 'text', text: 'live integration needle' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + caller.append('step/start', { turn: 1, step: 1 }) + + let call = 0 + const execute = (name: string, args: unknown) => ctx.tools.execute({ + name, + arguments: args, + callId: CallId(`integration-${++call}`), + signal: new AbortController().signal, + agent: fakeAgent(caller), + }) + + const sessions = await execute('session_search', { query: 'persisted integration needle' }) + expect(sessions.isError).toBe(false) + expect(sessions.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('Session persisted') + const persistedEvents = await execute('session_event_search', { + session_id: persisted, + query: 'persisted integration needle', + }) + expect(persistedEvents.isError).toBe(false) + expect(persistedEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('seq 0') + const liveEvents = await execute('session_event_search', { query: 'live integration needle' }) + expect(liveEvents.isError).toBe(false) + expect(liveEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('seq 1') + }) +}) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts new file mode 100644 index 0000000000..60136d4f83 --- /dev/null +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -0,0 +1,796 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context, type Fiber } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type Session, + type SessionHeader, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import SessionQueryService, { + SessionQueryError, + SessionSearchCursor, + type SessionEventSearchHit, + type SessionEventSearchRequest, + type SessionSearchExecContext, + type SessionSearchHit, + type SessionSearchPage, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' + +const activeContexts: Context[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + for (const ctx of activeContexts.splice(0)) await ctx.fiber.dispose() + FakeQuery.reset() +}) + +function header(id: string, cwd: string | undefined, createdAt = 1, parentSession?: SessionIdValue): SessionHeader { + return { + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt, + ...cwd === undefined ? {} : { cwd }, + ...parentSession === undefined ? {} : { parentSession }, + } +} + +function createSession( + ctx: Context, + id: string, + cwd: string | undefined, + createdAt = 1, + parentSession?: SessionIdValue, +): Session { + return ctx.sessions.create(SessionId(id), { + meta: { + createdAt, + ...cwd === undefined ? {} : { cwd }, + ...parentSession === undefined ? {} : { parentSession }, + }, + }) +} + +function openStep(session: Session, text = 'prior needle'): void { + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append( + 'user/message', + { content: [{ type: 'text', text }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + session.append('step/start', { turn: 1, step: 1 }) +} + +function fakeAgent(session: Session): Agent { + return { id: session.id, session } as unknown as Agent +} + +function sessionHit( + id: string, + cwd: string | undefined, + text = 'needle excerpt', + parentSession?: SessionIdValue, +): SessionSearchHit { + return { + header: header(id, cwd, 100, parentSession), + live: true, + persisted: false, + bestMatch: { + sessionId: SessionId(id), + seq: 4, + type: 'assistant/message', + time: 200, + surface: 'current', + snippet: text, + }, + } +} + +function eventHit(sessionId: SessionIdValue, seq: number, text = 'needle excerpt'): SessionEventSearchHit { + return { + sessionId, + seq, + type: 'user/message', + time: 200 + seq, + surface: 'current', + snippet: text, + } +} + +class FakeQuery extends SessionQueryService { + static sessionSearch: ( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ) => Promise> = () => Promise.resolve({ items: [] }) + + static eventSearch: ( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ) => Promise> = () => Promise.resolve({ items: [] }) + + static sessionRequests: SessionSearchRequest[] = [] + static eventRequests: SessionEventSearchRequest[] = [] + static searchSignals: Array = [] + static titles = new Map() + + static reset(): void { + this.sessionSearch = () => Promise.resolve({ items: [] }) + this.eventSearch = () => Promise.resolve({ items: [] }) + this.sessionRequests = [] + this.eventRequests = [] + this.searchSignals = [] + this.titles = new Map() + } + + override searchSessions( + request: SessionSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + FakeQuery.sessionRequests.push(request) + FakeQuery.searchSignals.push(exec?.signal) + return FakeQuery.sessionSearch(request, exec) + } + + override searchEvents( + request: SessionEventSearchRequest, + exec?: SessionSearchExecContext, + ): Promise> { + FakeQuery.eventRequests.push(request) + FakeQuery.searchSignals.push(exec?.signal) + return FakeQuery.eventSearch(request, exec) + } + + override async readTitle(sessionId: SessionIdValue) { + const value = FakeQuery.titles.get(sessionId) + if (value instanceof Error) throw value + if (value === undefined) return super.readTitle(sessionId) + return { + title: value, + messageSeqs: [], + source: { kind: 'fallback' as const }, + eventSeq: 0, + updatedAt: 1, + } + } +} + +interface Mounted { + readonly ctx: Context + readonly fiber: Fiber + readonly caller: Session + call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise +} + +async function mount( + config: ToolSessionQuery.Config = {}, + callerCwd: string | null = '/work', +): Promise { + const ctx = new Context() + activeContexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeQuery) + const fiber = await ctx.plugin(ToolSessionQuery, config) + const caller = createSession(ctx, 'caller', callerCwd ?? undefined, 10) + openStep(caller) + let calls = 0 + return { + ctx, + fiber, + caller, + call: (toolName, args, options = {}) => ctx.tools.execute({ + name: toolName, + arguments: args, + callId: CallId(`call-${++calls}`), + signal: options.signal ?? new AbortController().signal, + ...options.agent === undefined ? { agent: fakeAgent(caller) } : { agent: options.agent }, + }), + } +} + +function text(result: ToolExecutionResult): string { + return result.content.map(block => block.type === 'text' ? block.text : '').join('\n') +} + +function errorCode(result: ToolExecutionResult): string | undefined { + return result.isError ? result.error.info?.code : undefined +} + +describe('registration and schemas', () => { + it('registers the five cursor-free tools, prompt, timeouts, and pure generic presenters, then disposes them', async () => { + const mounted = await mount({ maxSearchResults: 7, searchTimeoutMs: 1234 }) + const names = mounted.ctx.tools.schemas().map(schema => schema.name) + expect(names).toEqual([ + 'session_search', + 'session_event_search', + 'session_trace', + 'session_event_trace', + 'session_event_read', + ]) + const sessionSchema = mounted.ctx.tools.schemas().find(schema => schema.name === 'session_search') + expect(sessionSchema?.parameters).not.toHaveProperty('properties.cursor') + expect(sessionSchema?.parameters).not.toHaveProperty('properties.limit') + expect(sessionSchema?.parameters).not.toHaveProperty('properties.cwd') + expect(mounted.ctx.tools.get('session_search')?.timeoutMs).toBe(1234) + expect(mounted.ctx.tools.get('session_trace')?.timeoutMs).toBeUndefined() + const safeArgs: Record = { + session_search: { query: 'q' }, + session_event_search: { query: 'q' }, + session_trace: {}, + session_event_trace: { seq: 0 }, + session_event_read: { seq: 0 }, + } + for (const name of names) { + expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(safeArgs[name])).toBe(true) + } + expect(mounted.ctx.tools.get('session_search')?.output.render({}, 'rendered')) + .toEqual([{ type: 'text', text: 'rendered' }]) + expect(mounted.ctx.tools.get('session_search')?.presentCall?.({ query: 'needle' })) + .toEqual({ card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: 'needle' }) + expect(mounted.ctx.tools.get('session_event_search')?.presentCall?.({ query: 'needle' })) + .toEqual({ card: 'generic', kind: 'search', title: 'Search session events', rawInput: 'needle' }) + expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({})) + .toEqual({ card: 'generic', kind: 'read', title: 'Trace current session' }) + expect(mounted.ctx.tools.get('session_trace')?.presentCall?.({ session_id: 'other' })) + .toEqual({ card: 'generic', kind: 'read', title: 'Trace session other', rawInput: 'other' }) + expect(mounted.ctx.tools.get('session_event_trace')?.presentCall?.({ session_id: 'other', seq: 3 })) + .toEqual({ + card: 'generic', + kind: 'read', + title: 'Trace event 3', + rawInput: { session_id: 'other', seq: 3 }, + }) + expect(mounted.ctx.tools.get('session_event_read')?.presentCall?.({ seq: 4 })) + .toEqual({ card: 'generic', kind: 'read', title: 'Read event 4', rawInput: { seq: 4 } }) + const assembly = await mounted.ctx.systemPrompt.assemble() + expect(assembly.sections.find(section => section.name === 'tool:session-query')?.text) + .toContain('prior sessions') + + await mounted.fiber.dispose() + expect(mounted.ctx.tools.schemas().map(schema => schema.name)).toEqual([]) + expect((await mounted.ctx.systemPrompt.assemble()).sections.map(section => section.name)) + .not.toContain('tool:session-query') + }) + + it('fails invalid direct config before registering anything', async () => { + const mounted = await mount() + for (const maxSearchResults of [0, 1.5, Number.NaN]) { + expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) }) + .toThrow('maxSearchResults') + } + for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) { + expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) }) + .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + } + expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow() + }) + + it('expresses the complete Node timer range in the Loader config schema', () => { + expect(new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS })) + .toEqual({ maxSearchResults: 100, searchTimeoutMs: MAX_TIMER_DELAY_MS }) + expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: 1.5 })).toThrow() + expect(() => new ToolSessionQuery.Config({ searchTimeoutMs: MAX_TIMER_DELAY_MS + 1 })).toThrow() + }) +}) + +describe('input validation and translation', () => { + it.each([ + [{ query: ' ' }, 'SESSION_QUERY_INVALID_QUERY'], + [{ query: 'bad\0query' }, 'SESSION_QUERY_INVALID_QUERY'], + [{ query: 'q', session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', parent_session_ids: [] }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', availability: [] }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', availability: ['archived'] }, 'INVALID_ARGS'], + [{ query: 'q', event_types: [] }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', event_surfaces: [] }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', event_surfaces: ['hidden'] }, 'INVALID_ARGS'], + [{ query: 'q', event_seq_from: -1 }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', event_seq_to: Number.MAX_SAFE_INTEGER + 1 }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', event_seq_from: 2, event_seq_to: 1 }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-07-24T10:00:00' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-02-30T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2100-02-29T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-04-31T10:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-01-01T24:00:00Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-01-01T00:60:00Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-01-01T00:00:60Z' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-01-01T00:00:00+24:00' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ query: 'q', created_at_from: '2026-01-01T00:00:00+00:60' }, 'SESSION_QUERY_INVALID_FILTER'], + [{ + query: 'q', + created_at_from: '2026-07-25T00:00:00Z', + created_at_to: '2026-07-24T00:00:00Z', + }, 'SESSION_QUERY_INVALID_FILTER'], + ])('rejects invalid search arguments %#', async (args, code) => { + const mounted = await mount() + const result = await mounted.call('session_search', args) + expect(errorCode(result)).toBe(code) + }) + + it('normalizes the query and compiles inclusive session/event filters with one parent OR clause', async () => { + const mounted = await mount() + await mounted.call('session_search', { + query: ' alpha beta ', + session_ids: ['a', 'b'], + created_at_from: '2026-07-24T00:00:00+08:00', + created_at_to: '2026-07-24T01:00:00+08:00', + parent_session_ids: ['parent'], + include_root_sessions: true, + availability: ['live'], + event_seq_from: 2, + event_seq_to: 9, + event_time_from: '2026-07-24T00:00:00Z', + event_time_to: '2026-07-24T01:00:00Z', + event_types: ['plugin/open-event'], + event_surfaces: ['shadowed'], + }) + expect(FakeQuery.sessionRequests).toHaveLength(1) + expect(FakeQuery.sessionRequests[0]).toEqual({ + query: 'alpha beta', + sessionFilters: [ + { kind: 'id', values: ['a', 'b'] }, + { + kind: 'created-at', + from: Date.parse('2026-07-24T00:00:00+08:00'), + to: Date.parse('2026-07-24T01:00:00+08:00'), + }, + { kind: 'parent', values: ['parent', null] }, + { kind: 'availability', values: ['live'] }, + { kind: 'cwd', values: ['/work'] }, + ], + eventFilters: [ + { kind: 'seq', from: 2, to: 9 }, + { + kind: 'time', + from: Date.parse('2026-07-24T00:00:00Z'), + to: Date.parse('2026-07-24T01:00:00Z'), + }, + { kind: 'type', values: ['plugin/open-event'] }, + { kind: 'surface', values: ['shadowed'] }, + ], + }) + }) + + it('compiles one-sided timestamps and independent root/parent clauses', async () => { + const mounted = await mount() + await mounted.call('session_search', { + query: 'q', + created_at_from: '2024-02-29T00:00Z', + include_root_sessions: true, + event_time_to: '2000-02-29T00:00Z', + }) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'created-at', + from: Date.parse('2024-02-29T00:00Z'), + }) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'parent', + values: [null], + }) + expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({ + kind: 'time', + to: Date.parse('2000-02-29T00:00Z'), + }) + + await mounted.call('session_search', { + query: 'q', + parent_session_ids: ['parent'], + }) + expect(FakeQuery.sessionRequests[1]?.sessionFilters).toContainEqual({ + kind: 'parent', + values: ['parent'], + }) + }) +}) + +describe('workspace authority and lineage redaction', () => { + it('fails closed without an agent and for direct cross-workspace targets', async () => { + const mounted = await mount() + createSession(mounted.ctx, 'outside', '/outside') + const missing = await mounted.ctx.tools.execute({ + name: 'session_trace', + arguments: {}, + callId: CallId('missing-agent'), + signal: new AbortController().signal, + }) + expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_MISSING_AGENT') + const denied = await mounted.call('session_event_read', { session_id: 'outside', seq: 0 }) + expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(denied)).not.toContain('session "outside"') + }) + + it('allows only self for a null-cwd caller and denies cross-session search', async () => { + const mounted = await mount({}, null) + const own = await mounted.call('session_trace', {}) + expect(own.isError).toBe(false) + expect(text(own)).toContain('Session caller') + expect(errorCode(await mounted.call('session_search', { query: 'q' }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + createSession(mounted.ctx, 'other', undefined) + expect(errorCode(await mounted.call('session_trace', { session_id: 'other' }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + }) + + it('redacts an unauthorized ancestor and prunes unauthorized descendant subtrees without hidden ids', async () => { + const mounted = await mount() + const hiddenParent = createSession(mounted.ctx, 'hidden-parent-secret', '/outside') + const target = createSession(mounted.ctx, 'target', '/work', 20, hiddenParent.id) + const visible = createSession(mounted.ctx, 'visible-child', '/work', 30, target.id) + const hidden = createSession(mounted.ctx, 'hidden-child-secret', '/outside', 40, target.id) + createSession(mounted.ctx, 'hidden-grandchild-secret', '/work', 50, hidden.id) + FakeQuery.titles.set(target.id, 'Target title') + FakeQuery.titles.set(visible.id, 'Visible title') + + const result = await mounted.call('session_trace', { session_id: target.id }) + const output = text(result) + expect(output).toContain('Target title') + expect(output).toContain('visible-child') + expect(output).toContain('[outside workspace boundary]') + expect(output).toContain('[outside workspace subtree]') + expect(output).not.toContain('hidden-parent-secret') + expect(output).not.toContain('hidden-child-secret') + expect(output).not.toContain('hidden-grandchild-secret') + }) + + it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => { + const mounted = await mount() + const root = createSession(mounted.ctx, 'visible-root', '/work', 5) + const target = createSession(mounted.ctx, 'visible-target', '/work', 6, root.id) + const complete = text(await mounted.call('session_trace', { session_id: target.id })) + expect(complete).toContain('visible-root') + + const missingParent = SessionId('missing-parent-secret') + const incomplete = createSession(mounted.ctx, 'incomplete-target', '/work', 7, missingParent) + const redacted = text(await mounted.call('session_trace', { session_id: incomplete.id })) + expect(redacted).toContain('[outside workspace boundary]') + expect(redacted).not.toContain(missingParent) + }) + + it('renders unavailable trace records and keeps a self-id descendant authorized', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'trace-unavailable', '/work') + const [record] = await mounted.ctx.sessionQuery.filterSessions([{ kind: 'id', values: [target.id] }]) + const [callerRecord] = await mounted.ctx.sessionQuery.filterSessions([{ + kind: 'id', + values: [mounted.caller.id], + }]) + if (record === undefined || callerRecord === undefined) throw new Error('expected live records') + const unavailable = { ...record, live: false, persisted: false } + const persisted = { ...callerRecord, live: false, persisted: true } + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: unavailable, + ancestors: [], + descendants: [{ session: persisted, descendants: [] }], + complete: true, + root: unavailable, + }) + const output = text(await mounted.call('session_trace', { session_id: target.id })) + expect(output).toContain('Availability: unavailable') + expect(output).toContain(mounted.caller.id) + expect(output).toContain('persisted') + }) +}) + +describe('search paging, prior-history bounds, titles, and cancellation', () => { + it('drains hidden internal pages to the authorized non-self cap and masks an unauthorized parent id', async () => { + const mounted = await mount({ maxSearchResults: 2 }) + const outside = createSession(mounted.ctx, 'outside-parent-secret', '/outside') + const a = createSession(mounted.ctx, 'a', '/work') + const b = createSession(mounted.ctx, 'b', '/work') + FakeQuery.titles.set(a.id, 'Alpha') + FakeQuery.titles.set(b.id, 'Beta') + const c1 = SessionSearchCursor('c1') + const c2 = SessionSearchCursor('c2') + FakeQuery.sessionSearch = (request) => { + if (request.cursor === undefined) { + return Promise.resolve({ + items: [ + sessionHit('caller', '/work'), + sessionHit('unauthorized', '/outside'), + ], + nextCursor: c1, + }) + } + if (request.cursor === c1) { + return Promise.resolve({ + items: [sessionHit('a', '/work', 'first', outside.id)], + nextCursor: c2, + }) + } + return Promise.resolve({ + items: [sessionHit('b', '/work', 'second')], + nextCursor: SessionSearchCursor('more'), + }) + } + + const result = await mounted.call('session_search', { query: 'needle' }) + const output = text(result) + expect(FakeQuery.sessionRequests).toHaveLength(3) + expect(FakeQuery.sessionRequests.every(request => request.limit === undefined)).toBe(true) + expect(FakeQuery.sessionRequests.map(request => request.cursor)).toEqual([undefined, c1, c2]) + expect(output).toContain('Session a — Alpha') + expect(output).toContain('Session b — Beta') + expect(output).toContain('Parent: [outside workspace]') + expect(output).not.toContain('outside-parent-secret') + expect(output).toContain('Result cap reached') + }) + + it('preserves stale-cursor diagnostics without transparently restarting', async () => { + const mounted = await mount({ maxSearchResults: 2 }) + const cursor = SessionSearchCursor('stale-next') + FakeQuery.sessionSearch = request => request.cursor === undefined + ? Promise.resolve({ items: [], nextCursor: cursor }) + : Promise.reject(new SessionQueryError('stale provider generation', 'SESSION_QUERY_STALE_CURSOR')) + const result = await mounted.call('session_search', { query: 'needle' }) + expect(errorCode(result)).toBe('SESSION_QUERY_STALE_CURSOR') + expect(text(result)).toContain('retry the complete search call') + expect(FakeQuery.sessionRequests).toHaveLength(2) + }) + + it('rejects a repeated internal cursor instead of looping', async () => { + const mounted = await mount() + const cursor = SessionSearchCursor('repeat') + FakeQuery.sessionSearch = () => Promise.resolve({ items: [], nextCursor: cursor }) + const result = await mounted.call('session_search', { query: 'needle' }) + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_CURSOR') + expect(FakeQuery.sessionRequests).toHaveLength(2) + }) + + it('renders authorized parent ids and all availability states', async () => { + const mounted = await mount({ maxSearchResults: 3 }) + const parent = createSession(mounted.ctx, 'parent', '/work') + const child = createSession(mounted.ctx, 'child', '/work', 2, parent.id) + const callerChild = createSession(mounted.ctx, 'caller-child', '/work', 3, mounted.caller.id) + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [ + { ...sessionHit(child.id, '/work', 'both', parent.id), live: true, persisted: true }, + { ...sessionHit(callerChild.id, '/work', 'persisted', mounted.caller.id), live: false, persisted: true }, + { ...sessionHit('unavailable', '/work', 'neither'), live: false, persisted: false }, + ], + }) + const output = text(await mounted.call('session_search', { query: 'needle' })) + expect(output).toContain('Parent: parent') + expect(output).toContain(`Parent: ${mounted.caller.id}`) + expect(output).toContain('Availability: live, persisted') + expect(output).toContain('Availability: persisted') + expect(output).toContain('Availability: unavailable') + }) + + it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => { + const mounted = await mount() + FakeQuery.eventSearch = request => Promise.resolve({ + items: [eventHit(request.sessionId, 1)], + }) + await mounted.call('session_event_search', { + query: 'prior', + seq_from: 0, + seq_to: 99, + }) + expect(FakeQuery.eventRequests[0]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 1 }) + + const other = createSession(mounted.ctx, 'other', '/work') + await mounted.call('session_event_search', { + session_id: other.id, + query: 'prior', + seq_from: 0, + seq_to: 99, + }) + expect(FakeQuery.eventRequests[1]?.filters).toContainEqual({ kind: 'seq', from: 0, to: 99 }) + }) + + it('returns no current-session hits without calling FTS when the user range starts in the active step', async () => { + const mounted = await mount() + const result = await mounted.call('session_event_search', { + query: 'prior', + seq_from: 2, + }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('No prior event matches found.') + expect(FakeQuery.eventRequests).toEqual([]) + }) + + it('requires a current step boundary and drains event pages to a capped result', async () => { + const mounted = await mount({ maxSearchResults: 2 }) + const noStep = createSession(mounted.ctx, 'no-step', '/work') + const missing = await mounted.call( + 'session_event_search', + { query: 'q' }, + { agent: fakeAgent(noStep) }, + ) + expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + + const other = createSession(mounted.ctx, 'paged-events', '/work') + const cursor = SessionSearchCursor('events-next') + FakeQuery.eventSearch = request => request.cursor === undefined + ? Promise.resolve({ items: [eventHit(other.id, 1)], nextCursor: cursor }) + : Promise.resolve({ items: [eventHit(other.id, 2), eventHit(other.id, 3)] }) + const result = await mounted.call('session_event_search', { + session_id: other.id, + query: 'q', + }) + expect(FakeQuery.eventRequests.map(request => request.cursor)).toEqual([undefined, cursor]) + expect(text(result)).toContain('Result cap reached') + }) + + it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'hit', '/work') + const failure = new HarnessError('title backend failed', 'TITLE_BACKEND') + FakeQuery.titles.set(hit.id, failure) + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + const result = await mounted.call('session_search', { query: 'needle' }) + expect(result.isError).toBe(false) + expect(text(result)).toContain('untitled (title unavailable: TITLE_BACKEND)') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('title backend failed')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('HarnessError')) + }) + + it('reports unknown title failures and preserves an Error without a stack', async () => { + const mounted = await mount() + const first = createSession(mounted.ctx, 'unknown-title', '/work') + const second = createSession(mounted.ctx, 'stackless-title', '/work') + const stackless = new Error('stackless') + Object.defineProperty(stackless, 'stack', { value: undefined }) + const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitle') + .mockRejectedValueOnce('string failure') + .mockRejectedValueOnce(stackless) + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [ + sessionHit(first.id, '/work'), + sessionHit(second.id, '/work'), + ], + }) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + const result = await mounted.call('session_search', { query: 'needle' }) + expect(text(result)).toContain('title unavailable: UNKNOWN') + expect(readTitle).toHaveBeenCalledTimes(2) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless')) + }) + + it('does not downgrade cancellation during title enrichment', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'abort-title', '/work') + const controller = new AbortController() + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitle').mockImplementation(() => { + controller.abort() + return Promise.reject(new Error('cancelled title')) + }) + const result = await mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) + expect(result.isError).toBe(true) + expect(text(result)).not.toContain('title unavailable') + }) + + it('passes the exact execution signal to every FTS page and stops on cancellation', async () => { + const mounted = await mount() + const controller = new AbortController() + let started!: () => void + const bodyStarted = new Promise((resolve) => { started = resolve }) + FakeQuery.sessionSearch = (_request, exec) => new Promise((_resolve, reject) => { + started() + exec?.signal?.addEventListener('abort', () => { + reject(new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED')) + }, { once: true }) + }) + const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) + await bodyStarted + controller.abort() + const result = await pending + expect(result.isError).toBe(true) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(FakeQuery.searchSignals).toEqual([controller.signal]) + }) +}) + +describe('trace and exact read rendering', () => { + it('renders every event relationship sequence and a UTC target timestamp', async () => { + const mounted = await mount() + const session = createSession(mounted.ctx, 'relationships', '/work') + session.append( + 'user/message', + { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + session.append( + 'assistant/message', + { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'replacement' }], + provenance: { provider: 'test', model: 'test' }, + }, + { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] }, + ) + const result = await mounted.call('session_event_trace', { session_id: session.id, seq: 0 }) + expect(text(result)).toContain('Replacement chain: 1') + expect(text(result)).toContain('Direct derived events: 1') + expect(text(result)).toContain(new Date(session.events[0]?.time ?? 0).toISOString()) + }) + + it('renders unabridged fenced target JSON and readable semantic neighbor summaries', async () => { + const mounted = await mount() + const session = createSession(mounted.ctx, 'read', '/work') + session.append( + 'user/message', + { content: [{ type: 'text', text: 'before semantic text' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + session.append( + 'assistant/message', + { + turn: 1, + step: 1, + content: [{ type: 'text', text: 'target full text' }], + provenance: { provider: 'test', model: 'test' }, + }, + { surfaceOp: 'append' }, + ) + session.append( + 'context/message', + { content: [{ type: 'text', text: 'after semantic text' }], source: { kind: 'plugin', plugin: 'test' } }, + { surfaceOp: 'append' }, + ) + const result = await mounted.call('session_event_read', { + session_id: session.id, + seq: 1, + before: 1, + after: 1, + }) + const output = text(result) + expect(output).toContain('```json') + expect(output).toContain('"text": "target full text"') + expect(output).toContain('before semantic text') + expect(output).toContain('after semantic text') + expect(output).not.toContain('truncated') + }) + + it('renders empty event relationships and neighbors without semantic text', async () => { + const mounted = await mount() + const session = createSession(mounted.ctx, 'empty-relations', '/work') + session.append('step/start', { turn: 1, step: 1 }) + session.append('step/end', { turn: 1, step: 1 }) + + const trace = text(await mounted.call('session_event_trace', { + session_id: session.id, + seq: 0, + })) + expect(trace).toContain('Replaced by: none') + expect(trace).toContain('Replacement chain: none') + + const onlyAfter = text(await mounted.call('session_event_read', { + session_id: session.id, + seq: 0, + after: 1, + })) + expect(onlyAfter).not.toContain('Before:') + expect(onlyAfter).toContain('(no semantic text)') + + const onlyBefore = text(await mounted.call('session_event_read', { + session_id: session.id, + seq: 1, + before: 1, + })) + expect(onlyBefore).toContain('Before:') + expect(onlyBefore).not.toContain('After:') + }) + + it.each([ + ['session_event_trace', { seq: -1 }], + ['session_event_read', { seq: Number.MAX_SAFE_INTEGER + 1 }], + ['session_event_read', { seq: 0, before: -1 }], + ['session_event_read', { seq: 0, after: 1.5 }, 'INVALID_ARGS'], + ])('rejects invalid exact-read integers for %s', async (name, args, expected = 'SESSION_QUERY_INVALID_FILTER') => { + const mounted = await mount() + expect(errorCode(await mounted.call(name, args))).toBe(expected) + }) +}) diff --git a/packages/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json new file mode 100644 index 0000000000..561b414216 --- /dev/null +++ b/packages/session-query/tool-session-query/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../session-query" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" + } + ] +} diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index b32c5c0574..a4cd595b12 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -12,11 +12,13 @@ const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' const MESSAGE_PREFIX = '{{messagePrefix}}' const UPDATED_AT = '{{updatedAt}}' +const EVENT_TIME = '{{eventTime}}' /** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */ const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g const PATH_TAG_RE = /()([^<]*)(<\/path>)/g const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g +const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -72,6 +74,9 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM } out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) + // Exact event-read tools render pretty JSON inside a text block. The event's + // wall-clock time is volatile even though its seq and payload are deterministic. + out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) return out diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index bdd85491d2..b74dd49e49 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -123,6 +123,28 @@ Additional instructions from: nested\AGENTS.md`, expect(out).not.toContain('2026-07-20T17:03:13.689Z') }) + it('stabilizes a pretty-printed event timestamp embedded in tool-result text', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + update: { + sessionUpdate: 'tool_call_update', + content: [{ + type: 'content', + content: { + type: 'text', + text: 'Target event:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```', + }, + }], + }, + }, + }) + const out = normalizeStdout(raw, ctx) + expect(out).toContain('\\"time\\": {{eventTime}}') + expect(out).not.toContain('1784876275593') + }) + it('throws on a non-JSON stdout line (the purity check)', () => { const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n` expect(() => normalizeStdout(raw, ctx)).toThrow() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e0d5cd798..322d6c658c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -363,6 +363,9 @@ importers: '@deepseek-ai/dsh-tool-ralph': specifier: workspace:* version: link:../packages/workflow/tool-ralph + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:* + version: link:../packages/session-query/tool-session-query '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent @@ -1348,6 +1351,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:../../session-query/tool-session-query '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -2115,6 +2121,9 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../session-query/session-query-sqlite '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session-title/session-title @@ -2163,6 +2172,9 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:^ version: link:../../fs/tool-fs-search + '@deepseek-ai/dsh-tool-session-query': + specifier: workspace:^ + version: link:../../session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../skill/tool-skill @@ -2900,6 +2912,52 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/session-query/tool-session-query: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-query': + specifier: workspace:^ + version: link:../session-query + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../session-query-sqlite + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-title/session-title: dependencies: schemastery: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5cfa05bc2f..309cd36c19 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -138,8 +138,8 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Session reads, traces, filters, and search', mode: 'seam', implementations: ['session-query-sqlite'], - consumers: ['session-reference'], - note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service.', + consumers: ['session-reference', 'tool-session-query'], + note: 'The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering.', }, { key: 'sessionReferences', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index cc85d48ba5..118ac61b0a 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -11,6 +11,8 @@ import { basename, resolve } from 'node:path' import { Context } from 'cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' import GoalService from '@deepseek-ai/dsh-goal' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' @@ -39,6 +41,7 @@ import * as ToolGoal from '@deepseek-ai/dsh-tool-goal' import Lsp from '@deepseek-ai/dsh-lsp' import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp' import * as ToolSkill from '@deepseek-ai/dsh-tool-skill' +import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' @@ -316,6 +319,20 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSkill) }, }, + { + pkg: '@deepseek-ai/dsh-tool-session-query', + dir: 'tool-session-query', + source: 'packages/session-query/tool-session-query/src/index.ts', + requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.sessionQuery', 'a calling Agent for workspace authority'], + writes: ['tool/call', 'tool/result'], + async mount(ctx) { + await ctx.plugin(SessionStore) + await ctx.plugin(SessionQuerySqlite, { path: ':memory:' }) + await ctx.plugin(ToolSessionQuery) + }, + note: + 'The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy.', + }, { pkg: '@deepseek-ai/dsh-tool-subagent', dir: 'tool-subagent', diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..b67bfb222d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -44,6 +44,7 @@ { "path": "./packages/session-persistence/session-persistence-sqlite" }, { "path": "./packages/session-query/session-query" }, { "path": "./packages/session-query/session-query-sqlite" }, + { "path": "./packages/session-query/tool-session-query" }, { "path": "./packages/session-title/session-title" }, { "path": "./packages/session-title/session-title-llm" }, { "path": "./packages/session-title/session-title-first-message-llm" }, From b488147c95a4f68b7da6026105fe4e285f53d9a5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 15:19:53 +0800 Subject: [PATCH 03/19] docs: correct session-query verification record (round 2) --- .../2026-07-24-model-facing-session-query-tools.i18n.yaml | 4 ++-- .../feature/2026-07-24-model-facing-session-query-tools.md | 2 +- .../feature/2026-07-24-model-facing-session-query-tools.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index feab70bc44..8169bc5977 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 27842c2799c3898de4bd9f0fd8171911b0e965cd -2026-07-24-model-facing-session-query-tools.zh.md: 899f63fb7bf6cc6357d25cf90e077b6e2f80afa1 +2026-07-24-model-facing-session-query-tools.md: 521b62fdc668f5c2e208118640be5cec99561a5c +2026-07-24-model-facing-session-query-tools.zh.md: 9be772b33e0e14f503ab2c762a831493381266fd diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 27842c2799..521b62fdc6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while a keyless model transcript pins the prompt guidance, schemas, representative search/trace/read output, and oversized-result spill behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 899f63fb7b..9be772b33e 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥模型 transcript 则固定提示词指导、schema、代表性搜索/追踪/读取输出和超大结果 spill 行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及精确事件读取的 spill 与保留行为。 ## 后果 From bc47884a770d2754c8023803435e69b44955aab9 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 15:42:24 +0800 Subject: [PATCH 04/19] fix session-query CI invariants --- examples/tui-agent/cordis.yml | 1 - packages/core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/examples/acp-demo/package.json | 1 - pnpm-lock.yaml | 3 --- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 479ad24d09..4fccfff1c7 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -7,7 +7,6 @@ - id: hmr name: '@cordisjs/plugin-hmr' - disabled: !!js process.env.CI === 'true' config: root: ['.'] diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index f3e32786b6..3754595f56 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index ad1ece0095..36de692404 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -70,7 +70,6 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 322d6c658c..3adfc3222b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,9 +1351,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-session-query': - specifier: workspace:^ - version: link:../../session-query/tool-session-query '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools From 01b70fd6fd6e91b29e27e868add3efd4b8a932ba Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 15:58:03 +0800 Subject: [PATCH 05/19] fix review findings for session queries --- .../tool-session-query/src/index.ts | 7 ++--- .../tests/tool-session-query.spec.ts | 27 +++++++++++++++++-- .../support/acp-snapshot/src/normalize.ts | 11 +++++--- .../acp-snapshot/tests/normalize.spec.ts | 24 ++++++++++++++++- 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index c8c7350604..02b6d2ceb8 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -658,13 +658,10 @@ async function collectPages( signal.throwIfAborted() for (const item of page.items) { if (!accept(item)) continue - items.push(item) if (items.length === maxResults) { - return { - items, - capped: page.nextCursor !== undefined || item !== page.items.at(-1), - } + return { items, capped: true } } + items.push(item) } if (page.nextCursor === undefined) return { items, capped: false } if (seen.has(page.nextCursor)) { diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 60136d4f83..017070eefc 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -506,8 +506,10 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }) } return Promise.resolve({ - items: [sessionHit('b', '/work', 'second')], - nextCursor: SessionSearchCursor('more'), + items: [ + sessionHit('b', '/work', 'second'), + sessionHit('additional-authorized', '/work', 'third'), + ], }) } @@ -523,6 +525,27 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(output).toContain('Result cap reached') }) + it('does not report a cap when only rejected hits remain after the authorized limit', async () => { + const mounted = await mount({ maxSearchResults: 1 }) + const cursor = SessionSearchCursor('rejected-tail') + FakeQuery.sessionSearch = request => request.cursor === undefined + ? Promise.resolve({ + items: [sessionHit('authorized', '/work')], + nextCursor: cursor, + }) + : Promise.resolve({ + items: [ + sessionHit(mounted.caller.id, '/work'), + sessionHit('outside', '/outside'), + ], + }) + + const output = text(await mounted.call('session_search', { query: 'needle' })) + expect(FakeQuery.sessionRequests.map(request => request.cursor)).toEqual([undefined, cursor]) + expect(output).toContain('Session authorized') + expect(output).not.toContain('Result cap reached') + }) + it('preserves stale-cursor diagnostics without transparently restarting', async () => { const mounted = await mount({ maxSearchResults: 2 }) const cursor = SessionSearchCursor('stale-next') diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index a4cd595b12..258de0fd53 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -19,6 +19,8 @@ const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g const PATH_TAG_RE = /()([^<]*)(<\/path>)/g const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g +const EVENT_READ_RESULT_RE + = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n/ /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -74,9 +76,12 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM } out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) - // Exact event-read tools render pretty JSON inside a text block. The event's - // wall-clock time is volatile even though its seq and payload are deterministic. - out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) + // Exact event-read results render pretty JSON inside a distinctive text + // envelope. Restrict time scrubbing to that envelope so JSON printed by + // models, bash, or unrelated tools remains regression-visible. + if (EVENT_READ_RESULT_RE.test(out)) { + out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) + } for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) return out diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index b74dd49e49..b4bc813cda 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -134,7 +134,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'Target event:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```', + text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```', }, }], }, @@ -145,6 +145,28 @@ Additional instructions from: nested\AGENTS.md`, expect(out).not.toContain('1784876275593') }) + it('preserves event-like timestamps in unrelated output text', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + update: { + sessionUpdate: 'tool_call_update', + content: [{ + type: 'content', + content: { + type: 'text', + text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```', + }, + }], + }, + }, + }) + const out = normalizeStdout(raw, ctx) + expect(out).toContain('1784876275593') + expect(out).not.toContain('{{eventTime}}') + }) + it('throws on a non-JSON stdout line (the purity check)', () => { const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n` expect(() => normalizeStdout(raw, ctx)).toThrow() From c1f364b33929493d970245cca3ca3b656795dcd7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 16:16:07 +0800 Subject: [PATCH 06/19] test: refresh session-query ACP snapshots --- .../system-prompt.expected.md | 78 ++++ .../tool-schemas.expected.json | 204 +++++++++ .../both-mode-turn/system-prompt.expected.md | 78 ++++ .../both-mode-turn/tool-schemas.expected.json | 204 +++++++++ .../code-mode-turn/system-prompt.expected.md | 78 ++++ .../system-prompt.expected.md | 78 ++++ .../lsp-definition/system-prompt.expected.md | 2 + .../lsp-definition/tool-schemas.expected.json | 204 +++++++++ .../model-switching/system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 408 ++++++++++++++++++ .../system-prompt.expected.md | 4 + .../tool-schemas.expected.json | 408 ++++++++++++++++++ .../plan-mode/system-prompt.expected.md | 4 + .../plan-mode/tool-schemas.expected.json | 408 ++++++++++++++++++ .../pty-tools/system-prompt.expected.md | 2 + .../pty-tools/tool-schemas.expected.json | 204 +++++++++ .../session-query-spill/session.jsonl | 8 +- .../session-query-spill/stdout.expected.jsonl | 2 +- .../skill-load/system-prompt.expected.md | 2 + .../skill-load/tool-schemas.expected.json | 204 +++++++++ .../system-prompt.expected.md | 2 + .../tool-schemas.expected.json | 204 +++++++++ 22 files changed, 2785 insertions(+), 5 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 73c31413bf..8fae73812d 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -139,6 +141,77 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ + session_event_read: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + /** Number of preceding raw events to summarize. Omit for none. */ + before?: number; + /** Number of following raw events to summarize. Omit for none. */ + after?: number; + } & Record; + /** Search prior events in one authorized session; the current session excludes the step performing this call. */ + session_event_search: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Literal full-text query over the target session. */ + query: string; + /** Inclusive event sequence lower bound. */ + seq_from?: number; + /** Inclusive event sequence upper bound. */ + seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read every direct replacement and provenance relationship for one event in an authorized session. */ + session_event_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + } & Record; + /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ + session_search: { + /** Literal full-text query over prior session history. */ + query: string; + /** Optional session ids to include. */ + session_ids?: string[]; + /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ + created_at_from?: string; + /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ + created_at_to?: string; + /** Optional direct parent session ids. */ + parent_session_ids?: string[]; + /** Include sessions with no parent in the parent filter. */ + include_root_sessions?: boolean; + /** Require at least one selected source availability. */ + availability?: ("live" | "persisted")[]; + /** Inclusive event sequence lower bound. */ + event_seq_from?: number; + /** Inclusive event sequence upper bound. */ + event_seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + event_time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + event_time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + event_surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ + session_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -348,6 +421,11 @@ interface ToolOutputMap { }[]; totalLines: number; }; + session_event_read: string; + session_event_search: string; + session_event_trace: string; + session_search: string; + session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 6b50a5d220..a0ab8af765 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -311,6 +311,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index 8744029272..014455b3fd 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -122,6 +124,77 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ + session_event_read: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + /** Number of preceding raw events to summarize. Omit for none. */ + before?: number; + /** Number of following raw events to summarize. Omit for none. */ + after?: number; + } & Record; + /** Search prior events in one authorized session; the current session excludes the step performing this call. */ + session_event_search: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Literal full-text query over the target session. */ + query: string; + /** Inclusive event sequence lower bound. */ + seq_from?: number; + /** Inclusive event sequence upper bound. */ + seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read every direct replacement and provenance relationship for one event in an authorized session. */ + session_event_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + } & Record; + /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ + session_search: { + /** Literal full-text query over prior session history. */ + query: string; + /** Optional session ids to include. */ + session_ids?: string[]; + /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ + created_at_from?: string; + /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ + created_at_to?: string; + /** Optional direct parent session ids. */ + parent_session_ids?: string[]; + /** Include sessions with no parent in the parent filter. */ + include_root_sessions?: boolean; + /** Require at least one selected source availability. */ + availability?: ("live" | "persisted")[]; + /** Inclusive event sequence lower bound. */ + event_seq_from?: number; + /** Inclusive event sequence upper bound. */ + event_seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + event_time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + event_time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + event_surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ + session_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -319,6 +392,11 @@ interface ToolOutputMap { }[]; totalLines: number; }; + session_event_read: string; + session_event_search: string; + session_event_trace: string; + session_search: string; + session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index 7ceeec4042..8626f46680 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -254,6 +254,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index 8744029272..014455b3fd 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -122,6 +124,77 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ + session_event_read: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + /** Number of preceding raw events to summarize. Omit for none. */ + before?: number; + /** Number of following raw events to summarize. Omit for none. */ + after?: number; + } & Record; + /** Search prior events in one authorized session; the current session excludes the step performing this call. */ + session_event_search: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Literal full-text query over the target session. */ + query: string; + /** Inclusive event sequence lower bound. */ + seq_from?: number; + /** Inclusive event sequence upper bound. */ + seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read every direct replacement and provenance relationship for one event in an authorized session. */ + session_event_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + } & Record; + /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ + session_search: { + /** Literal full-text query over prior session history. */ + query: string; + /** Optional session ids to include. */ + session_ids?: string[]; + /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ + created_at_from?: string; + /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ + created_at_to?: string; + /** Optional direct parent session ids. */ + parent_session_ids?: string[]; + /** Include sessions with no parent in the parent filter. */ + include_root_sessions?: boolean; + /** Require at least one selected source availability. */ + availability?: ("live" | "persisted")[]; + /** Inclusive event sequence lower bound. */ + event_seq_from?: number; + /** Inclusive event sequence upper bound. */ + event_seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + event_time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + event_time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + event_surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ + session_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -319,6 +392,11 @@ interface ToolOutputMap { }[]; totalLines: number; }; + session_event_read: string; + session_event_search: string; + session_event_trace: string; + session_search: string; + session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index 8744029272..014455b3fd 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -122,6 +124,77 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; + /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ + session_event_read: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + /** Number of preceding raw events to summarize. Omit for none. */ + before?: number; + /** Number of following raw events to summarize. Omit for none. */ + after?: number; + } & Record; + /** Search prior events in one authorized session; the current session excludes the step performing this call. */ + session_event_search: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Literal full-text query over the target session. */ + query: string; + /** Inclusive event sequence lower bound. */ + seq_from?: number; + /** Inclusive event sequence upper bound. */ + seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read every direct replacement and provenance relationship for one event in an authorized session. */ + session_event_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + /** Target event sequence number. */ + seq: number; + } & Record; + /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ + session_search: { + /** Literal full-text query over prior session history. */ + query: string; + /** Optional session ids to include. */ + session_ids?: string[]; + /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ + created_at_from?: string; + /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ + created_at_to?: string; + /** Optional direct parent session ids. */ + parent_session_ids?: string[]; + /** Include sessions with no parent in the parent filter. */ + include_root_sessions?: boolean; + /** Require at least one selected source availability. */ + availability?: ("live" | "persisted")[]; + /** Inclusive event sequence lower bound. */ + event_seq_from?: number; + /** Inclusive event sequence upper bound. */ + event_seq_to?: number; + /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ + event_time_from?: string; + /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ + event_time_to?: string; + /** Event types to include. */ + event_types?: string[]; + /** Event surfaces to include. */ + event_surfaces?: ("current" | "shadowed" | "log-only")[]; + } & Record; + /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ + session_trace: { + /** Target session id. Omit for the current session. */ + session_id?: string; + } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -319,6 +392,11 @@ interface ToolOutputMap { }[]; totalLines: number; }; + session_event_read: string; + session_event_search: string; + session_event_trace: string; + session_search: string; + session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index 7bde8fe289..cb50752e91 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -17,6 +17,8 @@ Track every background task id you start. You are notified in-session when a tas Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index b42a434388..4e34a49176 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -275,6 +275,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md index e5f8f35c02..371780ab3d 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/model-switching/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -43,6 +45,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json index ef40784fa5..8bfac915b0 100644 --- a/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/model-switching/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -788,6 +992,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md index 3ee1805568..55b309bbc3 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/permission-switching/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. @@ -42,6 +44,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json index ef40784fa5..8bfac915b0 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/permission-switching/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -788,6 +992,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md index df467239bd..a46e43ec2e 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/plan-mode/system-prompt.expected.md @@ -28,6 +28,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -56,6 +58,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json index ef40784fa5..8bfac915b0 100644 --- a/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/plan-mode/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", @@ -788,6 +992,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index df065a83cb..ccce83d9b7 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -17,6 +17,8 @@ Use a terminal session only when work needs persistent terminal state or interac Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index 529b1419da..0748a0f153 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index c4b4f54047..6d8a294c1e 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -15,12 +15,12 @@ {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"call_verify_session_query_spill","name":"bash","argumentsDelta":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}}}} {"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} {"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} -{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","name":"bash","arguments":"{\"command\":\"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \\\"$file\\\" && grep -q session_event_search \\\"$file\\\" && echo SPILL_CANONICAL_OK\",\"description\":\"Verify complete session query spill\"}"}} {"type":"tool/result","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"call_verify_session_query_spill","content":[{"type":"text","text":"SPILL_CANONICAL_OK\n"}],"isError":false},"sourceEventSeqs":[21],"surfaceOp":"append"} {"type":"step/end","seq":23,"time":0,"data":{"turn":1,"step":2}} {"type":"step/start","seq":24,"time":0,"data":{"turn":1,"step":3}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl index a1a65b452c..e0629f89a2 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -4,7 +4,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read request event 4 with","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"Read event 4","kind":"read","status":"in_progress","rawInput":{"seq":4}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); test $(wc -c < $file) -gt 40000 && grep -q request/header $file && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_CANONICAL_OK\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 17e6773a03..68bdd841c7 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index b01e7683d1..02237f770b 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 6cd8d5725f..45c9e0970c 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -15,6 +15,8 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index b01e7683d1..02237f770b 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -238,6 +238,210 @@ ] } }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", From 6be11539e26ab2aef651cca1e85943c5e692d1f5 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 16:40:08 +0800 Subject: [PATCH 07/19] fix: bind session authorization to observations --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 6 +- ...-24-model-facing-session-query-tools.zh.md | 6 +- docs/config-catalog.md | 4 +- docs/cordis-catalog/services.md | 19 ++- docs/core-data-structures/session-query.md | 30 +++++ .../session-query-spill/stdout.expected.jsonl | 2 +- .../tests/session-reference.spec.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 24 +++- .../session-query-sqlite/src/index.ts | 63 ++++++---- .../session-query-sqlite/tests/sqlite.spec.ts | 7 +- .../session-query/session-query/README.md | 6 +- .../session-query/session-query/src/index.ts | 33 ++++-- .../session-query/session-query/src/types.ts | 21 ++++ .../tests/search-helpers.spec.ts | 4 +- .../session-query/tests/test-service.ts | 13 ++- .../tool-session-query/src/index.ts | 60 +++++++--- .../tests/tool-session-query.spec.ts | 109 +++++++++++++++--- .../support/acp-snapshot/src/normalize.ts | 3 + .../acp-snapshot/tests/normalize.spec.ts | 8 +- packages/ui/acp/tests/harness.ts | 7 +- packages/ui/tui/tests/session-query.ts | 7 +- scripts/gen-cordis-catalog.ts | 3 + scripts/type-equiv.manifest.json | 15 +++ 24 files changed, 361 insertions(+), 100 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 8169bc5977..361145cb02 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 521b62fdc668f5c2e208118640be5cec99561a5c -2026-07-24-model-facing-session-query-tools.zh.md: 9be772b33e0e14f503ab2c762a831493381266fd +2026-07-24-model-facing-session-query-tools.md: 68169d8af7176ee1725a3c58bf97530f56a0765b +2026-07-24-model-facing-session-query-tools.zh.md: dfeae26a548b5e498e76fce259f7610106de7ec2 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 521b62fdc6..68169d8af7 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -18,7 +18,7 @@ Model-facing filters use flat snake-case fields. Timestamps are timezone-qualifi ## Workspace authority -Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its persisted `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter, direct reads and traces authorize before loading the target, and lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. +Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. @@ -28,7 +28,7 @@ Neither search tool exposes a cursor, offset, page size, or model-controlled res Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. -Session-level results include the latest folded title when available. Absence is rendered as untitled; a title read failure preserves the base result, renders an unavailable marker, and logs the underlying error. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. +Session-level results include the latest folded title when available. Absence is rendered as untitled; an operational title read failure preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. ## Host composition @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 9be772b33e..dfeae26a54 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 工作区权限 -每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标持久化的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件;直接读取与追踪在加载目标前完成授权;谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 +每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 @@ -28,7 +28,7 @@ Status: implemented 追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 -会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取失败时保留基础结果,渲染不可用标记,并记录底层错误。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 +会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取发生操作性失败时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 ## 宿主组合 @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及精确事件读取的 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d76443579a..3fb5238ff0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1027,7 +1027,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:74`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:75`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -1437,7 +1437,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:50`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:51`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..a01b275a30 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -986,9 +986,9 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec * Search events within one live-preferred logical session. * @param request - target session, query text, filters, page size, and cursor. * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. + * @returns matching event hits and their target header from one indexed generation. */ -abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise /** * List the complete logical corpus using live-preferred records. @@ -1010,6 +1010,13 @@ async filterSessions(filters: readonly SessionResultFilter[]): Promise +/** + * Fold the latest title and return its source header from one corpus observation. + * @param sessionId - live or persisted session id to read. + * @returns cloned source header and optional latest title snapshot. + */ +async readTitleSnapshot(sessionId: SessionId): Promise + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1044,10 +1051,10 @@ async traceSession(sessionId: SessionId): Promise /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. - * @returns direct links plus the target's positional replacement chain. + * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ -async traceEvent(request: SessionEventTraceRequest): Promise +async traceEvent(request: SessionEventTraceRequest): Promise /** * Read one full event plus a bounded raw-log context window. @@ -1057,9 +1064,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 0fe1596aaf..2cd7650dbc 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -39,6 +39,18 @@ interface SessionSurfaceSnapshot { } ``` +`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. + +```ts type-equiv +/** Latest folded title bound to the same session-header observation. */ +interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { @@ -146,6 +158,16 @@ interface SessionSearchPage { } ``` +Unlike grouped cross-session hits, a within-session search must also expose its observed target header even when the page contains no hits. + +```ts type-equiv +/** Event-search results bound to the indexed target-session observation. */ +interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} +``` + ```ts type-equiv /** One event full-text search hit with a bounded plain-text excerpt. */ interface SessionEventSearchHit extends SessionEventRecord { @@ -267,6 +289,14 @@ interface SessionEventTrace { } ``` +```ts type-equiv +/** Event relationships bound to the same session-header observation. */ +interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} +``` + ## Errors The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl index e0629f89a2..0f4bee73ca 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read request event 4 with","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"Read event 4","kind":"read","status":"in_progress","rawInput":{"seq":4}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted {{eventOmittedBytes}} bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_CANONICAL_OK\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 2470ae8d93..4bcc95af16 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -23,9 +23,12 @@ class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..caab8c0076 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -491,8 +491,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */', }, { - signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', - jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */', + signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise', + jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits and their target header from one indexed generation.\n */', }, { signature: 'listSessions(): Promise', @@ -506,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitle(sessionId: SessionId): Promise', jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */', }, + { + signature: 'async readTitleSnapshot(sessionId: SessionId): Promise', + jsDoc: '/**\n * Fold the latest title and return its source header from one corpus observation.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned source header and optional latest title snapshot.\n */', + }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -523,8 +527,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { - signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', - jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns direct links plus the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', + signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', + jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', }, { signature: 'async readEvent(request: SessionEventReadRequest): Promise', @@ -1761,6 +1765,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchHit', declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', }, + { + name: 'SessionEventSearchPage', + declaration: 'export interface SessionEventSearchPage extends SessionSearchPage {\n session: SessionHeader;\n}', + }, { name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', @@ -1773,6 +1781,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventTrace', declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}', }, + { + name: 'SessionEventTraceObservation', + declaration: 'export interface SessionEventTraceObservation extends SessionEventTrace {\n session: SessionHeader;\n}', + }, { name: 'SessionEventTraceRequest', declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}', @@ -1873,6 +1885,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleModelProvenance', declaration: 'export interface SessionTitleModelProvenance {\n readonly provider: string;\n readonly model: string;\n}', }, + { + name: 'SessionTitleObservation', + declaration: 'export interface SessionTitleObservation {\n session: SessionHeader;\n title?: SessionTitleSnapshot;\n}', + }, { name: 'SessionTitleProvider', declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise;\n}', diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 5e795d4d2c..b3ff8feb07 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -25,6 +25,7 @@ import type { Config as SessionQueryConfig, SessionEventSearchDocument, SessionEventSearchHit, + SessionEventSearchPage, SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, @@ -133,7 +134,7 @@ interface IndexedLiveRow { generation: number } -interface SearchRow { +interface SessionHeaderRow { session_id: string version: number created_at: number @@ -141,6 +142,9 @@ interface SearchRow { parent_session: string | null seed_length: number | null delegation_depth: number | null +} + +interface SearchRow extends SessionHeaderRow { live: number persisted: number seq: number @@ -247,27 +251,30 @@ export class SessionQuerySqlite extends SessionQueryService { override async searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> { + ): Promise { const normalized = normalizeEventRequest(request, this.config) const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) const persistenceBinding = await this._reconcile(signal) assertNotAborted(signal) - const generation = this._targetGeneration(normalized.sessionId, persistenceBinding) + const target = this._targetObservation(normalized.sessionId, persistenceBinding) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 - : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) + : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, target.generation) const rows = this._queryEvents(normalized, offset, persistenceBinding) - return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ - version: 1, - instance: this._instance, - scope: 'events', - fingerprint, - generation, - offset: cursorOffset, - }), offset) + return { + session: target.header, + ...page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ + version: 1, + instance: this._instance, + scope: 'events', + fingerprint, + generation: target.generation, + offset: cursorOffset, + }), offset), + } }) } @@ -643,17 +650,33 @@ export class SessionQuerySqlite extends SessionQueryService { `).all(...bindings) as unknown as SearchRow[] } - private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { + private _targetObservation( + sessionId: SessionId, + persistenceBinding: PersistenceBinding, + ): { header: SessionHeader; generation: string } { const db = this._requireDb() const live = db.prepare( - 'SELECT generation FROM temp.live_sessions WHERE id = ?', - ).get(sessionId) as { generation: number } | undefined - if (live !== undefined) return `live:${live.generation}` + `SELECT + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + FROM temp.live_sessions + WHERE id = ?`, + ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined + if (live !== undefined) { + return { header: rowHeader(live), generation: `live:${live.generation}` } + } if (persistenceBinding.service !== undefined) { const persisted = db.prepare( - 'SELECT generation FROM persisted_sessions WHERE id = ?', - ).get(sessionId) as { generation: number } | undefined - if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}` + `SELECT + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + FROM persisted_sessions + WHERE id = ?`, + ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined + if (persisted !== undefined) { + return { + header: rowHeader(persisted), + generation: `persisted:${this._persistenceEpoch}:${persisted.generation}`, + } + } } throw new SessionQueryError( `session "${sessionId}" not found`, @@ -835,7 +858,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) } -function rowHeader(row: SearchRow): SessionHeader { +function rowHeader(row: SessionHeaderRow): SessionHeader { return { version: row.version, id: row.session_id as SessionId, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 1923c6f3eb..71892159d5 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -179,7 +179,10 @@ describe('SQLite session search', () => { ) await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' })) - .resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] }) + .resolves.toMatchObject({ + session: { ...session.header, seedLength: 1 }, + items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }], + }) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })) .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) @@ -1307,7 +1310,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' })) .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) - .resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] }) + .resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) await search.dispose() diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index a83317ecf8..2b26ea8e43 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -7,12 +7,12 @@ - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. -- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title. +- `readTitleSnapshot(sessionId)` loads one live-preferred or persisted log and returns the cloned source header with its latest folded `session/title` event. `readTitle(sessionId)` is the title-only convenience view; it returns `undefined` when the known session has no title. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. +- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles. @@ -24,7 +24,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc ## Full-text methods -`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. +`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. An event-search page also carries the cloned target header from the same indexed generation as its hits, allowing authorization consumers to bind policy to the payload observation. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 2028f908c1..9f44a103fc 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -10,12 +10,12 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { SessionEventResultFilter, + SessionEventSearchPage, SessionEventReadRequest, SessionEventRecord, - SessionEventSearchHit, SessionEventSearchDocument, SessionEventSearchRequest, - SessionEventTrace, + SessionEventTraceObservation, SessionEventTraceRequest, SessionEventWindow, SessionLineageTrace, @@ -26,6 +26,7 @@ import type { SessionSearchPage, SessionSearchRequest, SessionSurfaceSnapshot, + SessionTitleObservation, } from './types.ts' import { SESSION_QUERY_READ_WINDOW_MAX, @@ -103,12 +104,12 @@ export abstract class SessionQueryService extends Service { * Search events within one live-preferred logical session. * @param request - target session, query text, filters, page size, and cursor. * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. + * @returns matching event hits and their target header from one indexed generation. */ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> + ): Promise /** * List the complete logical corpus using live-preferred records. @@ -134,8 +135,21 @@ export abstract class SessionQueryService extends Service { * @returns latest title snapshot, or `undefined` when the log has no title event. */ async readTitle(sessionId: SessionId): Promise { + return (await this.readTitleSnapshot(sessionId)).title + } + + /** + * Fold the latest title and return its source header from one corpus observation. + * @param sessionId - live or persisted session id to read. + * @returns cloned source header and optional latest title snapshot. + */ + async readTitleSnapshot(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return foldSessionTitle(loaded.events) + const title = foldSessionTitle(loaded.events) + return { + session: loaded.header, + ...title === undefined ? {} : { title }, + } } /** @@ -204,12 +218,15 @@ export abstract class SessionQueryService extends Service { /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. - * @returns direct links plus the target's positional replacement chain. + * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ - async traceEvent(request: SessionEventTraceRequest): Promise { + async traceEvent(request: SessionEventTraceRequest): Promise { const loaded = await this._corpus.load(request.sessionId) - return tracing.traceEvent(request.sessionId, loaded.events, request.seq) + return { + session: loaded.header, + ...tracing.traceEvent(request.sessionId, loaded.events, request.seq), + } } /** diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index b231bd9f78..72537918e1 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -12,6 +12,7 @@ import type { SessionId, SurfaceEvent, } from '@deepseek-ai/dsh-session' +import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { SessionSearchCursor } from './cursor.ts' export type { SessionSearchCursor } from './cursor.ts' @@ -108,6 +109,12 @@ export interface SessionEventTrace { derivedEventSeqs: number[] } +/** Event relationships bound to the same session-header observation. */ +export interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} + /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ @@ -134,6 +141,14 @@ export interface SessionEventWindow { endSeq: number } +/** Latest folded title bound to the same session-header observation. */ +export interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} + /** Inclusive numeric interval used by time and sequence filters. */ export interface SessionResultRange { /** Inclusive lower bound. */ @@ -184,6 +199,12 @@ export interface SessionSearchPage { nextCursor?: SessionSearchCursor } +/** Event-search results bound to the indexed target-session observation. */ +export interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} + /** Controls shared by cross-session and within-session search calls. */ export interface SessionSearchExecContext { /** Abort caller waiting and interrupt provider work where supported. */ diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 327048b8c1..e141487f32 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -213,8 +213,10 @@ it('registers exact and abstract search behavior under one ctx key', async () => const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TestSessionQueryService) + const session = ctx.sessions.create(id) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })) + .resolves.toEqual({ session: session.header, items: [] }) await fiber.dispose() expect(ctx.sessionQuery).toBeUndefined() }) diff --git a/packages/session-query/session-query/tests/test-service.ts b/packages/session-query/session-query/tests/test-service.ts index e37b0f71ff..9572e76e08 100644 --- a/packages/session-query/session-query/tests/test-service.ts +++ b/packages/session-query/session-query/tests/test-service.ts @@ -1,6 +1,6 @@ import SessionQueryService from '@deepseek-ai/dsh-session-query' import type { - SessionEventSearchHit, + SessionEventSearchPage, SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, @@ -17,10 +17,13 @@ export class TestSessionQueryService extends SessionQueryService { return Promise.resolve({ items: [] }) } - override searchEvents( - _request: SessionEventSearchRequest, + override async searchEvents( + request: SessionEventSearchRequest, _exec?: SessionSearchExecContext, - ): Promise> { - return Promise.resolve({ items: [] }) + ): Promise { + return { + session: (await this.readSurface(request.sessionId)).session, + items: [], + } } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 02b6d2ceb8..92be54d46b 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -20,9 +20,10 @@ import { extractSessionEventText, type SessionAvailability, type SessionEventMetadataFilter, + type SessionEventSearchPage, type SessionEventSearchHit, type SessionEventSurface, - type SessionEventTrace, + type SessionEventTraceObservation, type SessionEventWindow, type SessionLineageNode, type SessionLineageTrace, @@ -352,7 +353,7 @@ async function executeSessionSearch( .map(hit => hit.header.parentSession) .filter((id): id is SessionIdValue => id !== undefined) const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await readTitles(ctx, collected.items.map(hit => hit.header.id), exec.signal) + const titles = await readTitles(ctx, caller, collected.items.map(hit => hit.header.id), exec.signal) return formatSessionSearch(collected, titles, authorizedParents) } @@ -377,7 +378,7 @@ async function executeEventSearch( } range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) } - const title = await readTitle(ctx, sessionId, exec.signal) + const title = await readTitle(ctx, caller, sessionId, exec.signal) if (range.from !== undefined && range.to !== undefined && range.from > range.to) { return formatEventSearch(sessionId, title, { items: [], capped: false }) } @@ -392,12 +393,16 @@ async function executeEventSearch( const collected = await collectPages( maxResults, exec.signal, - cursor => ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }), + async (cursor): Promise => { + const page = await ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal }) + assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, () => true, ) return formatEventSearch(sessionId, title, collected) @@ -413,6 +418,7 @@ async function executeSessionTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await ctx.sessionQuery.traceSession(sessionId) exec.signal.throwIfAborted() + assertObservedTargetAuthorized(caller, sessionId, trace.target.header) const ancestors: SessionRecord[] = [] let ancestorBoundary = false @@ -430,7 +436,7 @@ async function executeSessionTrace( ...ancestors.map(record => record.header.id), ...descendantIds(descendants), ] - const titles = await readTitles(ctx, visibleIds, exec.signal) + const titles = await readTitles(ctx, caller, visibleIds, exec.signal) return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) } @@ -445,7 +451,8 @@ async function executeEventTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }) exec.signal.throwIfAborted() - const title = await readTitle(ctx, sessionId, exec.signal) + assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventTrace(sessionId, title, trace) } @@ -467,7 +474,8 @@ async function executeEventRead( ...args.after === undefined ? {} : { after: args.after }, }) exec.signal.throwIfAborted() - const title = await readTitle(ctx, sessionId, exec.signal) + assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventRead(sessionId, title, window) } @@ -676,8 +684,20 @@ async function collectPages( } function recordAuthorized(record: SessionRecord, caller: Caller): boolean { - if (record.header.id === caller.id) return true - return caller.header.cwd !== undefined && record.header.cwd === caller.header.cwd + return headerAuthorized(record.header, caller) +} + +function headerAuthorized(header: SessionHeader, caller: Caller): boolean { + if (header.id === caller.id) return true + return caller.header.cwd !== undefined && header.cwd === caller.header.cwd +} + +function assertObservedTargetAuthorized( + caller: Caller, + target: SessionIdValue, + observed: SessionHeader, +): void { + if (observed.id !== target || !headerAuthorized(observed, caller)) throw unauthorizedTarget() } async function authorizeSessionIds( @@ -704,28 +724,32 @@ async function authorizeSessionIds( async function readTitles( ctx: Context, + caller: Caller, ids: readonly SessionIdValue[], signal: AbortSignal, ): Promise { const result = new Map() for (const id of new Set(ids)) { - result.set(id, await readTitle(ctx, id, signal)) + result.set(id, await readTitle(ctx, caller, id, signal)) } return result as CompleteTitleMap } async function readTitle( ctx: Context, + caller: Caller, id: SessionIdValue, signal: AbortSignal, ): Promise { signal.throwIfAborted() try { - const title = await ctx.sessionQuery.readTitle(id) + const observation = await ctx.sessionQuery.readTitleSnapshot(id) signal.throwIfAborted() - return { text: title?.title ?? 'untitled' } + assertObservedTargetAuthorized(caller, id, observation.session) + return { text: observation.title?.title ?? 'untitled' } } catch (error: unknown) { if (signal.aborted) signal.throwIfAborted() + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error const code = error instanceof HarnessError ? error.code : 'UNKNOWN' ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) return { text: 'untitled', unavailableCode: code } @@ -866,7 +890,7 @@ function renderDescendants( function formatEventTrace( sessionId: SessionIdValue, title: TitleView, - trace: SessionEventTrace, + trace: SessionEventTraceObservation, ): string { return [ `Session ${sessionId} — ${titleText(title)}`, diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 017070eefc..ae62161f14 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -14,6 +14,7 @@ import SessionQueryService, { SessionQueryError, SessionSearchCursor, type SessionEventSearchHit, + type SessionEventSearchPage, type SessionEventSearchRequest, type SessionSearchExecContext, type SessionSearchHit, @@ -113,7 +114,10 @@ class FakeQuery extends SessionQueryService { static eventSearch: ( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ) => Promise> = () => Promise.resolve({ items: [] }) + ) => Promise = request => Promise.resolve({ + session: header(request.sessionId, '/work'), + items: [], + }) static sessionRequests: SessionSearchRequest[] = [] static eventRequests: SessionEventSearchRequest[] = [] @@ -122,7 +126,10 @@ class FakeQuery extends SessionQueryService { static reset(): void { this.sessionSearch = () => Promise.resolve({ items: [] }) - this.eventSearch = () => Promise.resolve({ items: [] }) + this.eventSearch = request => Promise.resolve({ + session: header(request.sessionId, '/work'), + items: [], + }) this.sessionRequests = [] this.eventRequests = [] this.searchSignals = [] @@ -141,22 +148,25 @@ class FakeQuery extends SessionQueryService { override searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> { + ): Promise { FakeQuery.eventRequests.push(request) FakeQuery.searchSignals.push(exec?.signal) return FakeQuery.eventSearch(request, exec) } - override async readTitle(sessionId: SessionIdValue) { + override async readTitleSnapshot(sessionId: SessionIdValue) { const value = FakeQuery.titles.get(sessionId) if (value instanceof Error) throw value - if (value === undefined) return super.readTitle(sessionId) + if (value === undefined) return super.readTitleSnapshot(sessionId) return { - title: value, - messageSeqs: [], - source: { kind: 'fallback' as const }, - eventSeq: 0, - updatedAt: 1, + session: (await this.readSurface(sessionId)).session, + title: { + title: value, + messageSeqs: [], + source: { kind: 'fallback' as const }, + eventSeq: 0, + updatedAt: 1, + }, } } } @@ -477,6 +487,69 @@ describe('workspace authority and lineage redaction', () => { expect(output).toContain(mounted.caller.id) expect(output).toContain('persisted') }) + + it('rejects every payload observation whose target moved after pre-authorization', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'moving-target', '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const movedHeader = header(target.id, '/outside') + + FakeQuery.eventSearch = () => Promise.resolve({ + session: movedHeader, + items: [eventHit(target.id, 0, 'secret event hit')], + }) + const search = await mounted.call('session_event_search', { + session_id: target.id, + query: 'secret', + }) + expect(errorCode(search)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(search)).not.toContain('secret event hit') + + const lineage = await mounted.ctx.sessionQuery.traceSession(target.id) + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValueOnce({ + ...lineage, + target: { ...lineage.target, header: movedHeader }, + }) + expect(errorCode(await mounted.call('session_trace', { session_id: target.id }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: 0 }) + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockResolvedValueOnce({ + ...eventTrace, + session: movedHeader, + }) + expect(errorCode(await mounted.call('session_event_trace', { session_id: target.id, seq: 0 }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: 0 }) + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({ + ...eventWindow, + session: movedHeader, + }) + expect(errorCode(await mounted.call('session_event_read', { session_id: target.id, seq: 0 }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [sessionHit(target.id, '/work', 'safe hit')], + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockResolvedValueOnce({ + session: movedHeader, + title: { + title: 'secret moved title', + messageSeqs: [], + source: { kind: 'fallback' }, + eventSeq: 0, + updatedAt: 1, + }, + }) + const titled = await mounted.call('session_search', { query: 'safe' }) + expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(titled)).not.toContain('secret moved title') + }) }) describe('search paging, prior-history bounds, titles, and cancellation', () => { @@ -590,6 +663,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => { const mounted = await mount() FakeQuery.eventSearch = request => Promise.resolve({ + session: header(request.sessionId, '/work'), items: [eventHit(request.sessionId, 1)], }) await mounted.call('session_event_search', { @@ -633,8 +707,15 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const other = createSession(mounted.ctx, 'paged-events', '/work') const cursor = SessionSearchCursor('events-next') FakeQuery.eventSearch = request => request.cursor === undefined - ? Promise.resolve({ items: [eventHit(other.id, 1)], nextCursor: cursor }) - : Promise.resolve({ items: [eventHit(other.id, 2), eventHit(other.id, 3)] }) + ? Promise.resolve({ + session: header(other.id, '/work'), + items: [eventHit(other.id, 1)], + nextCursor: cursor, + }) + : Promise.resolve({ + session: header(other.id, '/work'), + items: [eventHit(other.id, 2), eventHit(other.id, 3)], + }) const result = await mounted.call('session_event_search', { session_id: other.id, query: 'q', @@ -663,7 +744,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const second = createSession(mounted.ctx, 'stackless-title', '/work') const stackless = new Error('stackless') Object.defineProperty(stackless, 'stack', { value: undefined }) - const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitle') + const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot') .mockRejectedValueOnce('string failure') .mockRejectedValueOnce(stackless) FakeQuery.sessionSearch = () => Promise.resolve({ @@ -685,7 +766,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const hit = createSession(mounted.ctx, 'abort-title', '/work') const controller = new AbortController() FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) - vi.spyOn(mounted.ctx.sessionQuery, 'readTitle').mockImplementation(() => { + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(() => { controller.abort() return Promise.reject(new Error('cancelled title')) }) diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 258de0fd53..2de8d922db 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -13,12 +13,14 @@ const TOOLS = '{{tools}}' const MESSAGE_PREFIX = '{{messagePrefix}}' const UPDATED_AT = '{{updatedAt}}' const EVENT_TIME = '{{eventTime}}' +const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}' /** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */ const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g const PATH_TAG_RE = /()([^<]*)(<\/path>)/g const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g +const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g const EVENT_READ_RESULT_RE = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n/ @@ -81,6 +83,7 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM // models, bash, or unrelated tools remains regression-visible. if (EVENT_READ_RESULT_RE.test(out)) { out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) + out = out.replace(EVENT_READ_OMITTED_BYTES_RE, `$1${EVENT_OMITTED_BYTES}$2`) } for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index b4bc813cda..a4d0b4ad95 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -134,7 +134,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```', + text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', }, }], }, @@ -142,7 +142,9 @@ Additional instructions from: nested\AGENTS.md`, }) const out = normalizeStdout(raw, ctx) expect(out).toContain('\\"time\\": {{eventTime}}') + expect(out).toContain('Omitted {{eventOmittedBytes}} bytes') expect(out).not.toContain('1784876275593') + expect(out).not.toContain('39387') }) it('preserves event-like timestamps in unrelated output text', () => { @@ -156,7 +158,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```', + text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', }, }], }, @@ -164,7 +166,9 @@ Additional instructions from: nested\AGENTS.md`, }) const out = normalizeStdout(raw, ctx) expect(out).toContain('1784876275593') + expect(out).toContain('39387') expect(out).not.toContain('{{eventTime}}') + expect(out).not.toContain('{{eventOmittedBytes}}') }) it('throws on a non-JSON stdout line (the purity check)', () => { diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index fa7700c5f3..155ccb5b34 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -45,9 +45,12 @@ class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/packages/ui/tui/tests/session-query.ts b/packages/ui/tui/tests/session-query.ts index d9083ad6d1..67efcebf45 100644 --- a/packages/ui/tui/tests/session-query.ts +++ b/packages/ui/tui/tests/session-query.ts @@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..ef924b1b03 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -124,8 +124,10 @@ export const LINK_MAP: Record = { SessionEventResultFilter: 'session-query.md', SessionEventSearchDocument: 'session-query.md', SessionEventSearchHit: 'session-query.md', + SessionEventSearchPage: 'session-query.md', SessionEventSearchRequest: 'session-query.md', SessionEventTrace: 'session-query.md', + SessionEventTraceObservation: 'session-query.md', SessionEventTraceRequest: 'session-query.md', SessionEventWindow: 'session-query.md', SessionLineageTrace: 'session-query.md', @@ -135,6 +137,7 @@ export const LINK_MAP: Record = { SessionSearchHit: 'session-query.md', SessionSearchPage: 'session-query.md', SessionSearchRequest: 'session-query.md', + SessionTitleObservation: 'session-query.md', SessionTitleProvider: 'session-title.md', SessionTitleSnapshot: 'session-title.md', SkillDefinition: 'skills.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..6b7d86f77b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -394,6 +394,11 @@ "symbol": "SessionSurfaceSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionTitleObservation", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", @@ -434,6 +439,11 @@ "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventTraceObservation", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-reference.md", "symbol": "SessionReferenceInput", @@ -1194,6 +1204,11 @@ "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventSearchPage", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", From def026e5bc90c3b9eaecf4360c41e19a6b113b88 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 17:06:10 +0800 Subject: [PATCH 08/19] fix: harden session trace authorization --- .../tool-session-query/src/index.ts | 78 +++++++++--- .../tests/tool-session-query.spec.ts | 119 ++++++++++++++++++ 2 files changed, 183 insertions(+), 14 deletions(-) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 92be54d46b..e7b8b1ef88 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -131,6 +131,18 @@ interface AuthorizedDescendant { readonly descendants: Array } +interface DescendantProjectionFrame { + readonly node: SessionLineageNode + readonly target: Array + readonly next: DescendantProjectionFrame | undefined +} + +interface DescendantVisit { + readonly node: AuthorizedDescendant | null + readonly depth: number + readonly next: DescendantVisit | undefined +} + const SESSION_SEARCH_PARAMETERS = { query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, @@ -688,7 +700,7 @@ function recordAuthorized(record: SessionRecord, caller: Caller): boolean { } function headerAuthorized(header: SessionHeader, caller: Caller): boolean { - if (header.id === caller.id) return true + if (header.id === caller.id) return header.cwd === caller.header.cwd return caller.header.cwd !== undefined && header.cwd === caller.header.cwd } @@ -764,20 +776,60 @@ function authorizeDescendants( nodes: readonly SessionLineageNode[], caller: Caller, ): Array { - return nodes.map((node) => { - if (!recordAuthorized(node.session, caller)) return null - return { - record: node.session, - descendants: authorizeDescendants(node.descendants, caller), + const result: Array = [] + let pending: DescendantProjectionFrame | undefined + for (const node of [...nodes].reverse()) { + pending = { node, target: result, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + if (!recordAuthorized(current.node.session, caller)) { + current.target.push(null) + continue } - }) + const projected: AuthorizedDescendant = { + record: current.node.session, + descendants: [], + } + current.target.push(projected) + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + target: projected.descendants, + next: pending, + } + } + } + return result +} + +function * visitDescendants( + nodes: readonly (AuthorizedDescendant | null)[], +): Generator { + let pending: DescendantVisit | undefined + for (const node of [...nodes].reverse()) { + pending = { node, depth: 0, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + yield current + if (current.node === null) continue + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + depth: current.depth + 1, + next: pending, + } + } + } } function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { const ids: SessionIdValue[] = [] - for (const node of nodes) { - if (node === null) continue - ids.push(node.record.header.id, ...descendantIds(node.descendants)) + for (const { node } of visitDescendants(nodes)) { + if (node !== null) ids.push(node.record.header.id) } return ids } @@ -865,7 +917,7 @@ function formatSessionTrace( if (ancestorBoundary) lines.push('- [outside workspace boundary]') lines.push('', 'Descendants:') if (descendants.length === 0) lines.push('- none') - else renderDescendants(lines, descendants, titles, 0) + else renderDescendants(lines, descendants, titles) return lines.join('\n') } @@ -873,9 +925,8 @@ function renderDescendants( lines: string[], nodes: readonly (AuthorizedDescendant | null)[], titles: CompleteTitleMap, - depth: number, ): void { - for (const node of nodes) { + for (const { node, depth } of visitDescendants(nodes)) { const indent = ' '.repeat(depth) if (node === null) { lines.push(`${indent}- [outside workspace subtree]`) @@ -883,7 +934,6 @@ function renderDescendants( } const id = node.record.header.id lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) - renderDescendants(lines, node.descendants, titles, depth + 1) } } diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index ae62161f14..8aa8376d67 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -16,6 +16,7 @@ import SessionQueryService, { type SessionEventSearchHit, type SessionEventSearchPage, type SessionEventSearchRequest, + type SessionLineageNode, type SessionSearchExecContext, type SessionSearchHit, type SessionSearchPage, @@ -450,6 +451,65 @@ describe('workspace authority and lineage redaction', () => { expect(output).not.toContain('hidden-grandchild-secret') }) + it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'branch-target', '/work', 20) + const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{ + kind: 'id', + values: [target.id], + }]) + if (targetRecord === undefined) throw new Error('expected target record') + const firstId = SessionId('branch-first') + const nestedId = SessionId('branch-nested') + const hiddenId = SessionId('branch-hidden-secret') + const hiddenDescendantId = SessionId('branch-hidden-descendant-secret') + const lastId = SessionId('branch-last') + const descendants: SessionLineageNode[] = [ + { + session: { ...targetRecord, header: header(firstId, '/work', 30) }, + descendants: [ + { + session: { ...targetRecord, header: header(nestedId, '/work', 40) }, + descendants: [], + }, + { + session: { ...targetRecord, header: header(hiddenId, '/outside', 50) }, + descendants: [{ + session: { ...targetRecord, header: header(hiddenDescendantId, '/work', 60) }, + descendants: [], + }], + }, + ], + }, + { + session: { ...targetRecord, header: header(lastId, '/work', 70) }, + descendants: [], + }, + ] + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: targetRecord, + ancestors: [], + descendants, + complete: true, + root: targetRecord, + }) + const titleReads: SessionIdValue[] = [] + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation((sessionId) => { + titleReads.push(sessionId) + return Promise.resolve({ session: header(sessionId, '/work') }) + }) + + const output = text(await mounted.call('session_trace', { session_id: target.id })) + expect(output.slice(output.indexOf('Descendants:'))).toBe([ + 'Descendants:', + '- branch-first — untitled | 1970-01-01T00:00:00.030Z | live', + ' - branch-nested — untitled | 1970-01-01T00:00:00.040Z | live', + ' - [outside workspace subtree]', + '- branch-last — untitled | 1970-01-01T00:00:00.070Z | live', + ].join('\n')) + expect(titleReads).toEqual([target.id, firstId, nestedId, lastId]) + }) + it('renders authorized ancestors and an unresolved lineage boundary without leaking it', async () => { const mounted = await mount() const root = createSession(mounted.ctx, 'visible-root', '/work', 5) @@ -550,6 +610,30 @@ describe('workspace authority and lineage redaction', () => { expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') expect(text(titled)).not.toContain('secret moved title') }) + + it('rejects a default self read when its same-id observation moved after caller capture', async () => { + const mounted = await mount() + const secret = mounted.caller.append( + 'context/message', + { + content: [{ type: 'text', text: 'same-id moved secret' }], + source: { kind: 'plugin', plugin: 'test' }, + }, + { surfaceOp: 'append' }, + ) + const window = await mounted.ctx.sessionQuery.readEvent({ + sessionId: mounted.caller.id, + seq: secret.seq, + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({ + ...window, + session: header(mounted.caller.id, '/outside'), + }) + + const denied = await mounted.call('session_event_read', { seq: secret.seq }) + expect(errorCode(denied)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(denied)).not.toContain('same-id moved secret') + }) }) describe('search paging, prior-history bounds, titles, and cancellation', () => { @@ -797,6 +881,41 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }) describe('trace and exact read rendering', () => { + it('renders a deeply nested lineage without recursive consumer traversal', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'deep-target', '/work') + const [targetRecord] = await mounted.ctx.sessionQuery.filterSessions([{ + kind: 'id', + values: [target.id], + }]) + if (targetRecord === undefined) throw new Error('expected target record') + const depth = 3_000 + let descendants: SessionLineageNode[] = [] + for (let index = depth; index >= 1; index -= 1) { + descendants = [{ + session: { + ...targetRecord, + header: header(`deep-${index}`, '/work', index), + }, + descendants, + }] + } + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: targetRecord, + ancestors: [], + descendants, + complete: true, + root: targetRecord, + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(sessionId => Promise.resolve({ + session: header(sessionId, '/work'), + })) + + const output = text(await mounted.call('session_trace', { session_id: target.id })) + expect(output).toContain('Descendants:\n- deep-1 —') + expect(output).toContain(`${' '.repeat(depth - 1)}- deep-${depth} —`) + }) + it('renders every event relationship sequence and a UTC target timestamp', async () => { const mounted = await mount() const session = createSession(mounted.ctx, 'relationships', '/work') From f5fc7ac04a83719c8c067ad44c720bd0ed472fc7 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 17:25:32 +0800 Subject: [PATCH 09/19] test: scope event snapshot normalization --- .../support/acp-snapshot/src/normalize.ts | 19 +++++++++++-------- .../acp-snapshot/tests/normalize.spec.ts | 6 ++++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 2de8d922db..f21340bc59 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -19,10 +19,10 @@ const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}' const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g const PATH_TAG_RE = /()([^<]*)(<\/path>)/g const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g -const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g +const EMBEDDED_EVENT_TIME_RE = /^( "time": )\d+(?=,\r?$)/gm const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g -const EVENT_READ_RESULT_RE - = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n/ +const EVENT_READ_TARGET_REGION_RE + = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n[\s\S]*?(?=\r?\n```(?:\r?\n|$)|\r?\n\r?\n\(Omitted )/ /** A UUID v4 string, the shape `randomUUID()` produces for session ids. */ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -78,11 +78,14 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM } out = out.replace(LOCAL_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) out = out.replace(SNAPSHOT_SPILL_PATH_RE, (_match, name: string) => `{{spillLocator:${name}}}`) - // Exact event-read results render pretty JSON inside a distinctive text - // envelope. Restrict time scrubbing to that envelope so JSON printed by - // models, bash, or unrelated tools remains regression-visible. - if (EVENT_READ_RESULT_RE.test(out)) { - out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) + // Exact event-read results render the target as pretty JSON inside a + // distinctive envelope. Restrict time scrubbing to that fenced target so + // neighbor, model, bash, and unrelated tool text remains regression-visible. + if (EVENT_READ_TARGET_REGION_RE.test(out)) { + out = out.replace( + EVENT_READ_TARGET_REGION_RE, + target => target.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`), + ) out = out.replace(EVENT_READ_OMITTED_BYTES_RE, `$1${EVENT_OMITTED_BYTES}$2`) } for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index a4d0b4ad95..f2e4b74da7 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -123,7 +123,7 @@ Additional instructions from: nested\AGENTS.md`, expect(out).not.toContain('2026-07-20T17:03:13.689Z') }) - it('stabilizes a pretty-printed event timestamp embedded in tool-result text', () => { + it('stabilizes only the top-level event timestamp and spill byte count in event-read text', () => { const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', @@ -134,7 +134,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', + text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {\n "time": 31337,\n "note": "model-visible"\n }\n}\n```\n\nAfter:\n "time": 424242,\n neighbor semantic text\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', }, }], }, @@ -142,6 +142,8 @@ Additional instructions from: nested\AGENTS.md`, }) const out = normalizeStdout(raw, ctx) expect(out).toContain('\\"time\\": {{eventTime}}') + expect(out).toContain('\\"time\\": 31337') + expect(out).toContain('\\"time\\": 424242') expect(out).toContain('Omitted {{eventOmittedBytes}} bytes') expect(out).not.toContain('1784876275593') expect(out).not.toContain('39387') From fec4ce52cc06aacb9aeb2ad7a7cfe59c4cd068f2 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 18:13:11 +0800 Subject: [PATCH 10/19] fix: batch cancellable title reads --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 4 +- ...-24-model-facing-session-query-tools.zh.md | 4 +- docs/cordis-catalog/services.md | 27 +- docs/core-data-structures/session-query.md | 23 +- .../cordis/tool-cordis/src/api-catalog.ts | 24 +- .../session-persistence-jsonl/src/index.ts | 100 +++-- .../tests/zstd.spec.ts | 71 ++++ .../session-persistence-sqlite/src/index.ts | 18 +- .../session-persistence/README.md | 8 +- .../session-persistence/src/coordinator.ts | 94 ++++- .../session-persistence/src/index.ts | 6 +- .../session-persistence/tests/contract.ts | 15 + .../tests/persistence.spec.ts | 116 +++++- .../session-query/session-query/README.md | 4 +- .../session-query/session-query/src/corpus.ts | 184 ++++++++- .../session-query/session-query/src/index.ts | 46 ++- .../session-query/session-query/src/types.ts | 19 + .../session-query/tests/session-query.spec.ts | 362 +++++++++++++++++- .../tool-session-query/src/index.ts | 37 +- .../tests/tool-session-query.spec.ts | 130 +++++-- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 + 23 files changed, 1152 insertions(+), 150 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 361145cb02..7425619403 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 68169d8af7176ee1725a3c58bf97530f56a0765b -2026-07-24-model-facing-session-query-tools.zh.md: dfeae26a548b5e498e76fce259f7610106de7ec2 +2026-07-24-model-facing-session-query-tools.md: 0551adc431388d6cdd94b8e03c2976020ec90de4 +2026-07-24-model-facing-session-query-tools.zh.md: f82c0fac52d63ac3c11f48ee2769cb9e9590317c diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 68169d8af7..0551adc431 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -28,7 +28,7 @@ Neither search tool exposes a cursor, offset, page size, or model-controlled res Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. -Session-level results include the latest folded title when available. Absence is rendered as untitled; an operational title read failure preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. +Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most four persisted-inspection workers and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. ## Host composition @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index dfeae26a54..f82c0fac52 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -28,7 +28,7 @@ Status: implemented 追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 -会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取发生操作性失败时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 +会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用 4 个持久化检查 worker,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 ## 宿主组合 @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index de4296b85e..c2669885a4 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -941,15 +941,17 @@ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEven * This read is serialized with writes for the same id and returns detached * values, so observers cannot mutate backend-owned state. * @param id - the persisted session to inspect. + * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. */ -abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> +abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Lightweight listing from metadata, without a full-log parse. + * @param signal - optional cancellation for backend listing work. * @returns one header per materialized session. */ -abstract list(): Promise +abstract list(signal?: AbortSignal): Promise /** * List materialized sessions with cheap per-log change tokens. @@ -1014,16 +1016,29 @@ async filterSessions(filters: readonly SessionResultFilter[]): Promise +async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise /** * Fold the latest title and return its source header from one corpus observation. * @param sessionId - live or persisted session id to read. + * @param signal - optional cancellation for source resolution and title folding. * @returns cloned source header and optional latest title snapshot. */ -async readTitleSnapshot(sessionId: SessionId): Promise +async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise + +/** + * Fold titles for unique sessions from one cancellable corpus observation. + * + * Results preserve first-occurrence input order. Operational failures stay + * isolated per session, while cancellation rejects the complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ +async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise /** * List lightweight raw-log event records for one logical session. @@ -1072,9 +1087,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:75`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:76`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 265b49d3a2..4886eb3087 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -49,7 +49,7 @@ interface SessionSurfaceSnapshot { } ``` -`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. +`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. Batch reads return one ordered `SessionTitleObservationResult` per unique requested id: operational failures remain local to that id, while cancellation rejects the complete operation. ```ts type-equiv /** Latest folded title bound to the same session-header observation. */ @@ -61,6 +61,27 @@ interface SessionTitleObservation { } ``` +```ts type-equiv +/** One ordered result from a batch title observation. */ +type SessionTitleObservationResult = + | { + /** Requested session id. */ + sessionId: SessionId + /** Successful atomic header/title observation. */ + status: 'fulfilled' + /** Header and optional latest title from one logical source. */ + value: SessionTitleObservation + } + | { + /** Requested session id. */ + sessionId: SessionId + /** Operational failure isolated to this session. */ + status: 'rejected' + /** Original failure from logical-source resolution or title folding. */ + reason: unknown + } +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 43723acfa8..72785f4e69 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -469,12 +469,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */', }, { - signature: 'abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', - jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @returns the header and valid stored event prefix exactly as observed.\n */', + signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>', + jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */', }, { - signature: 'abstract list(): Promise', - jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @returns one header per materialized session.\n */', + signature: 'abstract list(signal?: AbortSignal): Promise', + jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */', }, { signature: 'abstract listSnapshots(): Promise', @@ -507,12 +507,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */', }, { - signature: 'async readTitle(sessionId: SessionId): Promise', - jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */', + signature: 'async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @param signal - optional cancellation for source resolution and title folding.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */', }, { - signature: 'async readTitleSnapshot(sessionId: SessionId): Promise', - jsDoc: '/**\n * Fold the latest title and return its source header from one corpus observation.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned source header and optional latest title snapshot.\n */', + signature: 'async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Fold the latest title and return its source header from one corpus observation.\n * @param sessionId - live or persisted session id to read.\n * @param signal - optional cancellation for source resolution and title folding.\n * @returns cloned source header and optional latest title snapshot.\n */', + }, + { + signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */', }, { signature: 'async listEvents(sessionId: SessionId): Promise', @@ -1897,6 +1901,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleObservation', declaration: 'export interface SessionTitleObservation {\n session: SessionHeader;\n title?: SessionTitleSnapshot;\n}', }, + { + name: 'SessionTitleObservationResult', + declaration: 'export type SessionTitleObservationResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: SessionTitleObservation;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};', + }, { name: 'SessionTitleProvider', declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise;\n}', diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 629c0e3ff1..6b2fe3d0cf 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -131,8 +131,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return this.coordinator.load(id) } - inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.inspect(id) + inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id, signal) } // One method serves both public `list` and the backend hook; delegating it to @@ -142,24 +142,33 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // --- PersistenceBackend hooks (the file-bytes storage primitives) --- /** Read a stored prefix by id across all cwd buckets when cwd is unknown. */ - async loadStored(id: SessionId): Promise | undefined> { + async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + signal?.throwIfAborted() await this.ensureRootEncoding() - const path = await this.findLog(id) + signal?.throwIfAborted() + const path = await this.findLog(id, signal) if (path === undefined) return undefined - return this.readPrefix(path, id) + return this.readPrefix(path, id, signal) } /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. */ - private async readPrefix(path: string, expectedId?: SessionId): Promise> { - const buffer = await readFile(path) + private async readPrefix( + path: string, + expectedId?: SessionId, + signal?: AbortSignal, + ): Promise> { + const buffer = await readFile(path, { signal }) + signal?.throwIfAborted() let prefix: StoredPrefix if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer) + prefix = await this.readZstdPrefix(buffer, signal) } else { + signal?.throwIfAborted() const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() prefix = { meta, events, @@ -168,30 +177,45 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi : {}, } } + signal?.throwIfAborted() this.assertStoredIdentity(path, prefix.meta, expectedId) return prefix } /** Decode complete frames and retain complete JSONL records from a torn final frame. */ - private async readZstdPrefix(buffer: Buffer): Promise> { + private async readZstdPrefix( + buffer: Buffer, + signal?: AbortSignal, + ): Promise> { + signal?.throwIfAborted() const { frames, tornStart } = scanZstdFrames(buffer) + signal?.throwIfAborted() if (frames.length === 0) throw new Error('empty or header-less Zstandard session log') const plaintextFrames: Buffer[] = [] for (const frame of frames) { + let plaintext: Buffer try { - plaintextFrames.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end))) + signal?.throwIfAborted() + plaintext = await decompressZstdFrame(buffer.subarray(frame.start, frame.end)) } catch (error) { + /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ + if (signal?.aborted) signal.throwIfAborted() throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error }) } + signal?.throwIfAborted() + plaintextFrames.push(plaintext) } const headerFrame = plaintextFrames[0] if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) { throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') } + signal?.throwIfAborted() const completePlaintext = Buffer.concat(plaintextFrames) + signal?.throwIfAborted() const completePrefix = scanLog(completePlaintext) + signal?.throwIfAborted() if (completePrefix.committedBytes !== completePlaintext.length) { throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record') } @@ -201,12 +225,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi let recoveredPlaintext: Buffer = Buffer.alloc(0) try { + signal?.throwIfAborted() recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart)) } catch { + /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ + if (signal?.aborted) signal.throwIfAborted() // A structurally incomplete final frame may end before Node's decoder can // emit any plaintext; the complete prior frames remain recoverable. } + signal?.throwIfAborted() const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext])) + signal?.throwIfAborted() /* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */ if (recoveredPrefix.events.length < completePrefix.events.length) { throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames') @@ -247,8 +276,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** List valid unique stored sessions' metadata (header line only — no full-log parse). */ - async list(): Promise { - return (await this.listArtifacts()).map(artifact => artifact.header) + async list(signal?: AbortSignal): Promise { + return (await this.listArtifacts(signal)).map(artifact => artifact.header) } /** List metadata plus a stat-derived identity for each append-only log. */ @@ -274,17 +303,21 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi return snapshots } - private async listArtifacts(): Promise> { + private async listArtifacts(signal?: AbortSignal): Promise> { + signal?.throwIfAborted() await this.ensureRootEncoding() + signal?.throwIfAborted() const artifacts: Array<{ header: SessionHeader; path: string }> = [] const ids = new Set() - for (const dir of await this.listCwdDirs()) { - for (const name of await this.listArtifactNames(dir)) { + for (const dir of await this.listCwdDirs(signal)) { + for (const name of await this.listArtifactNames(dir, signal)) { + signal?.throwIfAborted() const path = join(dir, name) // Read only headers so listing scales with session count, not log size. const first = this.compression === 'zstd' - ? await this.readFirstZstdLine(path) - : await this.readFirstLine(path) + ? await this.readFirstZstdLine(path, signal) + : await this.readFirstLine(path, signal) + signal?.throwIfAborted() if (first === undefined) continue // empty/half-written file const meta = parseHeaderMeta(first) if (meta === undefined) continue // not a session header @@ -492,18 +525,23 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi * file. Returns undefined if the file is empty or has no complete first line. * Reads in bounded chunks so a huge log costs only the header read. */ - private async readFirstLine(path: string): Promise { + private async readFirstLine(path: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const handle = await open(path, 'r') try { + signal?.throwIfAborted() const chunks: Buffer[] = [] const buf = Buffer.alloc(8192) for (;;) { + signal?.throwIfAborted() const { bytesRead } = await handle.read(buf, 0, buf.length, null) + signal?.throwIfAborted() if (bytesRead === 0) return undefined // EOF with no newline → no complete line const slice = buf.subarray(0, bytesRead) const nl = slice.indexOf(0x0a) if (nl !== -1) { chunks.push(slice.subarray(0, nl)) + signal?.throwIfAborted() return Buffer.concat(chunks).toString('utf8') } chunks.push(Buffer.from(slice)) @@ -514,23 +552,34 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** Read and validate only the independently compressed header frame. */ - private async readFirstZstdLine(path: string): Promise { + private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const handle = await open(path, 'r') try { + signal?.throwIfAborted() let content = Buffer.alloc(0) const chunk = Buffer.alloc(8192) for (;;) { + signal?.throwIfAborted() const { bytesRead } = await handle.read(chunk, 0, chunk.length, null) + signal?.throwIfAborted() if (bytesRead === 0) return undefined + signal?.throwIfAborted() content = Buffer.concat([content, chunk.subarray(0, bytesRead)]) + signal?.throwIfAborted() const first = scanZstdFrames(content, 1).frames[0] + signal?.throwIfAborted() if (first === undefined) continue let plaintext: Buffer try { + signal?.throwIfAborted() plaintext = await decompressZstdFrame(content.subarray(first.start, first.end)) } catch (error) { + /* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */ + if (signal?.aborted) signal.throwIfAborted() throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error }) } + signal?.throwIfAborted() if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) { throw new Error('corrupt Zstandard session log: first frame is not exactly one header line') } @@ -542,11 +591,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** Find the unique physical log for an id across every cwd bucket. */ - private async findLog(id: SessionId): Promise { + private async findLog(id: SessionId, signal?: AbortSignal): Promise { const target = encodeSegment(id) + logSuffix(this.compression) const oppositeTarget = encodeSegment(id) + logSuffix(this.oppositeCompression()) const matches: string[] = [] - for (const dir of await this.listCwdDirs()) { + for (const dir of await this.listCwdDirs(signal)) { + signal?.throwIfAborted() const path = join(dir, target) const opposite = join(dir, oppositeTarget) if (await this.exists(opposite)) throw this.encodingMismatch(opposite) @@ -585,9 +635,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** The cwd-bucket directories under the root (absolute paths). */ - private async listCwdDirs(): Promise { + private async listCwdDirs(signal?: AbortSignal): Promise { try { + signal?.throwIfAborted() const entries = await readdir(this.root, { withFileTypes: true }) + signal?.throwIfAborted() return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name)) } catch (error) { // Only an absent root means no sessions; rethrow every other I/O failure. @@ -596,8 +648,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } - private async listArtifactNames(dir: string): Promise { + private async listArtifactNames(dir: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const entries = await readdir(dir) + signal?.throwIfAborted() const oppositeSuffix = logSuffix(this.oppositeCompression()) const incompatible = entries.find(name => name.endsWith(oppositeSuffix)) if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`) diff --git a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts index fcadac1f04..2777281481 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/zstd.spec.ts @@ -16,6 +16,18 @@ const MAGIC = Buffer.from([0x28, 0xB5, 0x2F, 0xFD]) const roots: string[] = [] const contexts: Context[] = [] +interface ZstdReaderInternals { + readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise +} + +type HeaderRead = ( + this: FileHandle, + buffer: Buffer, + offset: number, + length: number, + position: number | null, +) => Promise<{ bytesRead: number; buffer: Buffer }> + async function freshRoot(prefix = 'dsh-jsonl-zstd-'): Promise { const root = await mkdtemp(join(tmpdir(), prefix)) roots.push(root) @@ -275,6 +287,65 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/) }) + it('stops multi-frame inspection after cancellation interrupts the active decode', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('cancel-zstd-frames') + const headerFrame = await compressZstdFrame(`${JSON.stringify(toHeaderLine(header))}\n`) + const eventFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[0])}\n`) + const laterFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[1])}\n`) + const stream = Buffer.concat([headerFrame, eventFrame, laterFrame]) + expect(scanZstdFrames(stream).frames).toHaveLength(3) + const controller = new AbortController() + const reason = new Error('cancel after Zstandard decode starts') + const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals + const zstdModule = await import('../src/zstd.ts') + const decode = vi.spyOn(zstdModule, 'decompressZstdFrame') + + // readZstdPrefix reaches its first asynchronous decompression before it + // returns this promise. The microtask abort therefore occurs after decode + // starts and must prevent every later frame from reaching the decoder. + const pending = reader.readZstdPrefix(stream, controller.signal) + queueMicrotask(() => { controller.abort(reason) }) + + await expect(pending).rejects.toBe(reason) + expect(decode).toHaveBeenCalledTimes(1) + expect(decode).toHaveBeenCalledWith(headerFrame) + }) + + it.each(['none', 'zstd'] as const)( + 'observes cancellation after each async %s header read during listing', + async (compression) => { + const root = await freshRoot() + const ctx = await mount(root, compression) + const header = meta(`cancel-${compression}-header-read`, '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + await ctx.sessionPersistence.list() + const path = logPath(root, header.cwd, header.id, compression) + const probe = await open(path, 'r') + const prototype = Object.getPrototypeOf(probe) as { read: HeaderRead } + const originalRead = prototype.read + await probe.close() + const controller = new AbortController() + const reason = new Error(`cancel ${compression} header read`) + const read = vi.spyOn(prototype, 'read').mockImplementation(async function ( + this: FileHandle, + buffer: Buffer, + offset: number, + length: number, + position: number | null, + ) { + const result = await originalRead.call(this, buffer, offset, length, position) + controller.abort(reason) + return result + }) + + await expect(ctx.sessionPersistence.list(controller.signal)).rejects.toBe(reason) + expect(read).toHaveBeenCalledTimes(1) + }, + ) + it('preserves complete records from a torn frame and re-encodes them with crash closers', async () => { const root = await freshRoot() const ctx = await mount(root) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 5804c18282..0c1159f139 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -157,8 +157,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers return this.coordinator.load(id) } - inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.inspect(id) + inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id, signal) } // One method serves both public `list` and the backend hook; delegating it to @@ -167,8 +167,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers // --- PersistenceBackend hooks (the SQLite storage primitives) --- /** Read a stored prefix by id (ids are globally unique — no scope to scan). */ - loadStored(id: SessionId): Promise | undefined> { - return this.readPrefix(id) + loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + return this.readPrefix(id, signal) } /** @@ -176,14 +176,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers * torn-tail marker is the seq from which a never-committed tail must be deleted * (`scanRows` already returns it as `number | undefined`). */ - private async readPrefix(id: SessionId): Promise | undefined> { + private async readPrefix(id: SessionId, signal?: AbortSignal): Promise | undefined> { + signal?.throwIfAborted() await this.ready + signal?.throwIfAborted() const row = this.rowFor(id) if (row === undefined) return undefined const meta = rowToMeta(row) const eventRows = this.db .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') .all(id) as unknown as EventRow[] + signal?.throwIfAborted() const { preserved, tornFrom } = scanRows(eventRows) return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} } } @@ -251,11 +254,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } /** List all materialized sessions' metadata (every row is a materialized session). */ - async list(): Promise { + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted() await this.ready + signal?.throwIfAborted() const rows = this.db .prepare('SELECT * FROM sessions') .all() as unknown as SessionRow[] + signal?.throwIfAborted() return rows.map(rowToMeta) } diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index 25429bd720..fa734fb736 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -12,8 +12,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | -| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. | -| `list(): Promise` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. | +| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | +| `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | ## Invariants every backend must honor @@ -40,10 +40,10 @@ The `PersistenceBackend` hooks (the only seam between the coordinato | Hook | Role | |---|---| | `name` | Backend label for the dispose-failure `AggregateError`. | -| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | +| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | -| `list()` | List all stored metadata. | +| `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md). diff --git a/packages/session-persistence/session-persistence/src/coordinator.ts b/packages/session-persistence/session-persistence/src/coordinator.ts index fb46aa4877..442011bd2c 100644 --- a/packages/session-persistence/session-persistence/src/coordinator.ts +++ b/packages/session-persistence/session-persistence/src/coordinator.ts @@ -40,8 +40,10 @@ export interface PersistenceBackend { * `id` before repair or state publication. Used by resume/load, live adoption, * and — via `!== undefined` — the create-collision probe. The returned * `tornMarker` is present iff there is a torn tail to truncate. + * @param id - persisted session id to resolve. + * @param signal - optional cancellation for backend read work. */ - loadStored(id: SessionId): Promise | undefined> + loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> /** * Durably append a CONTIGUOUS batch, lazily materializing the session first @@ -60,8 +62,11 @@ export interface PersistenceBackend { */ commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise - /** List all stored (materialized) sessions' metadata. */ - list(): Promise + /** + * List all stored (materialized) sessions' metadata. + * @param signal - optional cancellation for backend listing work. + */ + list(signal?: AbortSignal): Promise /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the @@ -267,14 +272,26 @@ export class PersistenceCoordinator { * Read a detached valid stored prefix without recovery mutations or * coordinator-state publication. * @param id - persisted session to inspect. + * @param signal - optional cancellation for queued and backend read work. * @returns stored header and events before any synthetic recovery closers. */ - inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.serialize(id, () => this.inspectCore(id)) + inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.serialize(id, () => this.inspectCore(id, signal), signal) } - private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const stored = await this.backend.loadStored(id) + private async inspectCore( + id: SessionId, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + signal?.throwIfAborted() + let stored: StoredPrefix | undefined + try { + stored = await this.backend.loadStored(id, signal) + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + throw error + } + signal?.throwIfAborted() if (stored === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, stored.meta) this.assertVersion(stored.meta) @@ -331,9 +348,19 @@ export class PersistenceCoordinator { * public methods must NOT call each other (deadlock); they call the unserialized * `*Core` helpers instead. */ - private serialize(id: SessionId, op: () => Promise | T): Promise { + private serialize( + id: SessionId, + op: () => Promise | T, + signal?: AbortSignal, + ): Promise { const prior = this.chains.get(id) ?? Promise.resolve() - const next = prior.then(op, op) + let started = false + const run = (): Promise | T => { + signal?.throwIfAborted() + started = true + return op() + } + const next = prior.then(run, run) // Keep the chain alive but swallow this op's rejection for the NEXT waiter // (the caller still sees the real rejection via `next`). const tail = next.then(() => undefined, () => undefined) @@ -343,7 +370,7 @@ export class PersistenceCoordinator { void tail.then(() => { if (this.chains.get(id) === tail) this.chains.delete(id) }) - return next + return signal === undefined ? next : observeQueuedAbort(next, signal, () => started) } /** Build a state for a session discovered in storage but not yet in memory. */ @@ -615,3 +642,50 @@ export class PersistenceCoordinator { live.pending.splice(0, batch.length) } } + +/** + * Give an observation caller a prompt cancellation view of queued work. + * + * The serialized `operation` remains in the same-id chain and checks the signal + * before invoking backend work. Observing its settlement here therefore cannot + * detach a storage read or let a later operation overtake its predecessor. + */ +function observeQueuedAbort( + operation: Promise, + signal: AbortSignal, + started: () => boolean, +): Promise { + return new Promise((resolve, reject) => { + let settled = false + const finish = (callback: () => void): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', onAbort) + callback() + } + const onAbort = (): void => { + if (started()) return + finish(() => { + try { + signal.throwIfAborted() + } catch (reason: unknown) { + rejectObservation(reject, reason) + return + } + /* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted */ + reject(new Error('persistence observation abort event lacked an aborted signal')) + }) + } + signal.addEventListener('abort', onAbort, { once: true }) + operation.then( + (value) => { finish(() => { resolve(value) }) }, + (reason: unknown) => { finish(() => { rejectObservation(reject, reason) }) }, + ) + if (signal.aborted) onAbort() + }) +} + +/** Preserve an exact provider or AbortSignal reason, including legacy non-Error values. */ +function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void { + reject(reason) +} diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index c785c9354c..9eee07a323 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -103,15 +103,17 @@ export abstract class SessionPersistence extends Service { * This read is serialized with writes for the same id and returns detached * values, so observers cannot mutate backend-owned state. * @param id - the persisted session to inspect. + * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. */ - abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Lightweight listing from metadata, without a full-log parse. + * @param signal - optional cancellation for backend listing work. * @returns one header per materialized session. */ - abstract list(): Promise + abstract list(signal?: AbortSignal): Promise /** * List materialized sessions with cheap per-log change tokens. diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index ae07bf77aa..eb77235057 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -222,6 +222,21 @@ export function runPersistenceContract(name: string, make: () => Promise { + const { persistence, dispose } = await make() + try { + const reason = new Error('persistence observation cancelled') + const controller = new AbortController() + controller.abort(reason) + + await expect(persistence.list(controller.signal)).rejects.toBe(reason) + await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal)) + .rejects.toBe(reason) + } finally { + await dispose() + } + }) + it('lists stable lightweight revisions that change after an append', async () => { const { persistence, dispose } = await make() try { diff --git a/packages/session-persistence/session-persistence/tests/persistence.spec.ts b/packages/session-persistence/session-persistence/tests/persistence.spec.ts index 6b31d0843b..523a88089e 100644 --- a/packages/session-persistence/session-persistence/tests/persistence.spec.ts +++ b/packages/session-persistence/session-persistence/tests/persistence.spec.ts @@ -94,8 +94,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.load(id) } - inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - return this.coordinator.inspect(id) + inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + return this.coordinator.inspect(id, signal) } // --- PersistenceBackend hooks (the Map storage primitives) --- @@ -132,7 +132,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[]) } - async list(): Promise { + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted() return [...this.store.values()].map(e => structuredClone(e.meta)) } @@ -153,10 +154,10 @@ class ControlledBackend implements PersistenceBackend { loadAttempts = 0 repairAttempts = 0 beforeAppend?: (attempt: number) => Promise - beforeLoadStored?: (attempt: number) => Promise + beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise - async loadStored(id: SessionId): Promise | undefined> { - await this.beforeLoadStored?.(++this.loadAttempts) + async loadStored(id: SessionId, signal?: AbortSignal): Promise | undefined> { + await this.beforeLoadStored?.(++this.loadAttempts, signal) const entry = this.store.get(id) if (entry === undefined) return undefined return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) } @@ -348,6 +349,109 @@ describe('PersistenceCoordinator stored identity', () => { }) }) +describe('PersistenceCoordinator observation cancellation', () => { + it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('queued-inspect-cancellation') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + const loadGate = Promise.withResolvers() + backend.beforeLoadStored = async (attempt) => { + if (attempt === 1) await loadGate.promise + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const prior = coordinator.inspect(id) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + const controller = new AbortController() + const reason = new Error('queued inspect cancelled') + const queued = coordinator.inspect(id, controller.signal) + let observedReason: unknown + const observedAbort = queued.catch((error: unknown) => { + observedReason = error + }) + + controller.abort(reason) + + await vi.waitFor(() => { expect(observedReason).toBe(reason) }) + expect(backend.loadAttempts).toBe(1) + const subsequent = coordinator.inspect(id) + expect(backend.loadAttempts).toBe(1) + + loadGate.resolve(true) + await expect(prior).resolves.toMatchObject({ meta: { id } }) + await observedAbort + await expect(subsequent).resolves.toMatchObject({ meta: { id } }) + expect(backend.loadAttempts).toBe(2) + await vi.waitFor(() => { + expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0) + }) + } finally { + loadGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) + + it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const backend = new ControlledBackend() + const id = SessionId('active-inspect-cancellation') + backend.store.set(id, { meta: meta(id), events: oneTurnLog() }) + const cleanupGate = Promise.withResolvers() + let cleanupComplete = false + backend.beforeLoadStored = async (_attempt, signal) => { + await new Promise((resolve) => { + signal?.addEventListener('abort', () => { + void cleanupGate.promise.then(() => { + cleanupComplete = true + resolve() + }) + }, { once: true }) + }) + throw new Error('backend cancellation after cleanup') + } + let coordinator!: PersistenceCoordinator + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, backend) + }, { inject: ['sessions'] })) + + try { + const controller = new AbortController() + const reason = new Error('active inspect cancelled') + const pending = coordinator.inspect(id, controller.signal) + let observedReason: unknown + const observed = pending.catch((error: unknown) => { + observedReason = error + }) + await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) }) + + controller.abort(reason) + await Promise.resolve() + + expect(observedReason).toBeUndefined() + expect(cleanupComplete).toBe(false) + cleanupGate.resolve(true) + await observed + expect(cleanupComplete).toBe(true) + expect(observedReason).toBe(reason) + const backendFailure = new Error('later inspection failure') + backend.beforeLoadStored = () => Promise.reject(backendFailure) + await expect(coordinator.inspect(id)).rejects.toBe(backendFailure) + } finally { + cleanupGate.resolve(true) + await fiber.dispose() + await ctx.fiber.dispose() + } + }) +}) + describe('PersistenceCoordinator retirement', () => { it('a retiring unmaterialized owner without buffered events releases its id', async () => { const ctx = new Context() diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 9ee495f48a..8c74f96e3c 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -8,14 +8,14 @@ - `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. -- `readTitleSnapshot(sessionId)` loads one live-preferred or persisted log and returns the cloned source header with its latest folded `session/title` event. `readTitle(sessionId)` is the title-only convenience view; it returns `undefined` when the known session has no title. +- `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most four workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 0e1753d5ce..ebf0f40bbc 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -15,6 +15,22 @@ export interface LogicalSession { events: SessionEvent[] } +/** Borrowed source visible only during one synchronous batch projection. */ +export interface LogicalSessionSource { + /** Header selected with `events`; callers must clone retained output. */ + readonly header: SessionHeader + /** Raw events selected with `header`; valid only for the projection call. */ + readonly events: readonly SessionEvent[] +} + +/** One source-projection result in a batch logical-corpus observation. */ +export type LogicalProjectionResult = + | { sessionId: SessionId; status: 'fulfilled'; value: Value } + | { sessionId: SessionId; status: 'rejected'; reason: unknown } + +/** Bound persisted observation fan-out for public batch title reads. */ +const PERSISTED_INSPECT_CONCURRENCY = 4 + /** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { private _persistence: SessionPersistence | undefined @@ -72,16 +88,7 @@ export class SessionCorpus { if (persistence === undefined) throw notFound(sessionId) const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) if (listed === undefined) throw notFound(sessionId) - let loaded: Awaited> - try { - loaded = await persistence.inspect(sessionId) - } catch (error: unknown) { - throw new SessionQueryError( - `failed to inspect session "${sessionId}": ${errorMessage(error)}`, - 'SESSION_QUERY_PERSISTENCE_FAILED', - { cause: error }, - ) - } + const loaded = await inspectPersisted(persistence, sessionId) const attached = this._ctx.sessions.get(sessionId) if (attached !== undefined) return snapshotLive(attached) assertSessionHeadersCompatible(loaded.meta, listed) @@ -90,11 +97,147 @@ export class SessionCorpus { events: loaded.events.map(event => structuredClone(event)), } } + + /** + * Project unique logical sources immediately from one persistence listing. + * + * The synchronous projector runs before a persisted worker claims its next id. + * Full logs are borrowed only for that call and never retained by the batch. + * @param sessionIds - sessions to resolve in first-occurrence order. + * @param project - synchronous fold that owns/clones every retained value. + * @param signal - cancellation shared by listing and every persisted inspection. + * @returns one fulfilled or rejected projected result per unique requested id. + */ + async projectMany( + sessionIds: readonly SessionId[], + project: (source: LogicalSessionSource) => Value, + signal?: AbortSignal, + ): Promise[]> { + const ids = [...new Set(sessionIds)] + signal?.throwIfAborted() + const resolved = new Map>() + const unresolved: SessionId[] = [] + for (const id of ids) { + const session = this._ctx.sessions.get(id) + if (session === undefined) { + unresolved.push(id) + } else { + resolved.set(id, projectSource(id, sourceLive(session), project, signal)) + } + } + if (unresolved.length === 0) return orderedResults(ids, resolved) + + const persistence = this._persistence + if (persistence === undefined) { + for (const sessionId of unresolved) { + resolved.set(sessionId, { sessionId, status: 'rejected', reason: notFound(sessionId) }) + } + return orderedResults(ids, resolved) + } + + let persisted: SessionHeader[] + try { + persisted = await listPersisted(persistence, signal) + signal?.throwIfAborted() + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + for (const sessionId of unresolved) { + resolved.set(sessionId, { sessionId, status: 'rejected', reason: error }) + } + return orderedResults(ids, resolved) + } + const persistedById = new Map(persisted.map(header => [header.id, header])) + const resolvePersisted = async (sessionId: SessionId): Promise => { + const listed = persistedById.get(sessionId) + if (listed === undefined) { + const attached = this._ctx.sessions.get(sessionId) + resolved.set(sessionId, attached === undefined + ? { sessionId, status: 'rejected', reason: notFound(sessionId) } + : projectSource(sessionId, sourceLive(attached), project, signal)) + return + } + try { + signal?.throwIfAborted() + const loaded = await inspectPersisted(persistence, sessionId, signal) + signal?.throwIfAborted() + const attached = this._ctx.sessions.get(sessionId) + if (attached !== undefined) { + resolved.set(sessionId, projectSource(sessionId, sourceLive(attached), project, signal)) + return + } + assertSessionHeadersCompatible(loaded.meta, listed) + resolved.set(sessionId, projectSource(sessionId, { + header: loaded.meta, + events: loaded.events, + }, project, signal)) + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + resolved.set(sessionId, { sessionId, status: 'rejected', reason: error }) + } + } + let cursor = 0 + const worker = async (): Promise => { + for (;;) { + signal?.throwIfAborted() + const index = cursor + if (index >= unresolved.length) return + cursor += 1 + await resolvePersisted(unresolved[index] as SessionId) + } + } + const workerCount = Math.min(PERSISTED_INSPECT_CONCURRENCY, unresolved.length) + const settlements = await Promise.allSettled( + Array.from({ length: workerCount }, () => worker()), + ) + if (signal?.aborted) signal.throwIfAborted() + /* v8 ignore start -- per-id failures settle inside resolvePersisted; workers reject only on abort above */ + for (const settlement of settlements) { + if (settlement.status === 'rejected') { + const reason: unknown = settlement.reason + throw reason + } + } + /* v8 ignore stop */ + signal?.throwIfAborted() + return orderedResults(ids, resolved) + } } -async function listPersisted(persistence: SessionPersistence): Promise { +function projectSource( + sessionId: SessionId, + source: LogicalSessionSource, + project: (source: LogicalSessionSource) => Value, + signal?: AbortSignal, +): LogicalProjectionResult { try { - return await persistence.list() + signal?.throwIfAborted() + const value = project(source) + signal?.throwIfAborted() + return { sessionId, status: 'fulfilled', value } + } catch (reason: unknown) { + /* v8 ignore next -- the synchronous projector has no external cancellation yield */ + if (signal?.aborted) signal.throwIfAborted() + return { sessionId, status: 'rejected', reason } + } +} + +function sourceLive(session: Session): LogicalSessionSource { + return { header: session.header, events: session.events } +} + +function orderedResults( + ids: readonly SessionId[], + resolved: ReadonlyMap>, +): LogicalProjectionResult[] { + return ids.map(sessionId => resolved.get(sessionId) as LogicalProjectionResult) +} + +async function listPersisted( + persistence: SessionPersistence, + signal?: AbortSignal, +): Promise { + try { + return await persistence.list(signal) } catch (error: unknown) { throw new SessionQueryError( `session persistence listing failed: ${errorMessage(error)}`, @@ -104,6 +247,23 @@ async function listPersisted(persistence: SessionPersistence): Promise>> { + try { + return await persistence.inspect(sessionId, signal) + } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() + throw new SessionQueryError( + `failed to inspect session "${sessionId}": ${errorMessage(error)}`, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause: error }, + ) + } +} + function snapshotLive(session: Session): LogicalSession { return { header: structuredClone(session.header), diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index b38914b3b6..000eb83425 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -28,6 +28,7 @@ import type { SessionSearchRequest, SessionSurfaceSnapshot, SessionTitleObservation, + SessionTitleObservationResult, } from './types.ts' import { SESSION_QUERY_READ_WINDOW_MAX, @@ -148,24 +149,51 @@ export abstract class SessionQueryService extends Service { /** * Fold the latest log-backed title from one live-preferred logical session. * @param sessionId - live or persisted session id to read. + * @param signal - optional cancellation for source resolution and title folding. * @returns latest title snapshot, or `undefined` when the log has no title event. */ - async readTitle(sessionId: SessionId): Promise { - return (await this.readTitleSnapshot(sessionId)).title + async readTitle( + sessionId: SessionId, + signal?: AbortSignal, + ): Promise { + return (await this.readTitleSnapshot(sessionId, signal)).title } /** * Fold the latest title and return its source header from one corpus observation. * @param sessionId - live or persisted session id to read. + * @param signal - optional cancellation for source resolution and title folding. * @returns cloned source header and optional latest title snapshot. */ - async readTitleSnapshot(sessionId: SessionId): Promise { - const loaded = await this._corpus.load(sessionId) - const title = foldSessionTitle(loaded.events) - return { - session: loaded.header, - ...title === undefined ? {} : { title }, - } + async readTitleSnapshot( + sessionId: SessionId, + signal?: AbortSignal, + ): Promise { + const result = (await this.readTitleSnapshots([sessionId], signal))[0] as SessionTitleObservationResult + if (result.status === 'rejected') throw result.reason + return result.value + } + + /** + * Fold titles for unique sessions from one cancellable corpus observation. + * + * Results preserve first-occurrence input order. Operational failures stay + * isolated per session, while cancellation rejects the complete operation. + * @param sessionIds - live or persisted session ids to observe. + * @param signal - optional cancellation shared by all source reads. + * @returns one fulfilled or rejected result per unique requested id. + */ + async readTitleSnapshots( + sessionIds: readonly SessionId[], + signal?: AbortSignal, + ): Promise { + return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => { + const title = foldSessionTitle(source.events) + return { + session: structuredClone(source.header), + ...title === undefined ? {} : { title }, + } + }, signal) } /** diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index d3e7196152..b01d80dade 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -157,6 +157,25 @@ export interface SessionTitleObservation { title?: SessionTitleSnapshot } +/** One ordered result from a batch title observation. */ +export type SessionTitleObservationResult = + | { + /** Requested session id. */ + sessionId: SessionId + /** Successful atomic header/title observation. */ + status: 'fulfilled' + /** Header and optional latest title from one logical source. */ + value: SessionTitleObservation + } + | { + /** Requested session id. */ + sessionId: SessionId + /** Operational failure isolated to this session. */ + status: 'rejected' + /** Original failure from logical-source resolution or title folding. */ + reason: unknown + } + /** Inclusive numeric interval used by time and sequence filters. */ export interface SessionResultRange { /** Inclusive lower bound. */ diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index a69c3bcb93..f88d8be0f6 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' @@ -27,16 +27,31 @@ function eventLog(text = 'hello'): SessionEvent[] { class TestPersistence extends SessionPersistence { static entries = new Map() static listFailure: unknown + static listOverride: ((signal?: AbortSignal) => Promise) | undefined static inspectFailure: unknown static inspectEffect: (() => void) | undefined + static inspectOverride: (( + id: SessionIdType, + signal?: AbortSignal, + ) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined static afterList: (() => void) | undefined + static listCalls = 0 + static inspectCalls: SessionIdType[] = [] + static listSignals: Array = [] + static inspectSignals: Array = [] static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void { this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)])) this.listFailure = undefined + this.listOverride = undefined this.inspectFailure = undefined this.inspectEffect = undefined + this.inspectOverride = undefined this.afterList = undefined + this.listCalls = 0 + this.inspectCalls = [] + this.listSignals = [] + this.inspectSignals = [] } locate(_meta: SessionHeader): undefined { @@ -59,7 +74,15 @@ class TestPersistence extends SessionPersistence { return this.inspect(id) } - inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + inspect( + id: SessionIdType, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + TestPersistence.inspectCalls.push(id) + TestPersistence.inspectSignals.push(signal) + if (TestPersistence.inspectOverride !== undefined) { + return TestPersistence.inspectOverride(id, signal) + } if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure) const entry = TestPersistence.entries.get(id) if (entry === undefined) return Promise.reject(new Error('missing test session')) @@ -69,7 +92,10 @@ class TestPersistence extends SessionPersistence { return Promise.resolve(result) } - list(): Promise { + list(signal?: AbortSignal): Promise { + TestPersistence.listCalls += 1 + TestPersistence.listSignals.push(signal) + if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal) if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure) const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)) TestPersistence.afterList?.() @@ -192,6 +218,336 @@ describe('session-query exact reads', () => { expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted']) }) + it('batches unique persisted title observations through one cancellable corpus scan', async () => { + const first = header('batch-title-first', 1) + const second = header('batch-title-second', 2) + const titleEvent = (title: string, time: number): SessionEvent => ({ + type: 'session/title', + seq: 0, + time, + data: { + title, + messageSeqs: [], + source: { kind: 'fallback' }, + }, + }) + TestPersistence.reset([ + { meta: first, events: [titleEvent('First title', 10)] }, + { meta: second, events: [titleEvent('Second title', 20)] }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const signal = new AbortController().signal + const missing = SessionId('batch-title-missing') + + const results = await ctx.sessionQuery.readTitleSnapshots( + [second.id, first.id, second.id, missing], + signal, + ) + + expect(results.map(result => [result.sessionId, result.status])).toEqual([ + [second.id, 'fulfilled'], + [first.id, 'fulfilled'], + [missing, 'rejected'], + ]) + expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } }) + expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } }) + expect(TestPersistence.listCalls).toBe(1) + expect(TestPersistence.inspectCalls).toEqual([second.id, first.id]) + expect(TestPersistence.listSignals).toEqual([signal]) + expect(TestPersistence.inspectSignals).toEqual([signal, signal]) + }) + + it('bounds persisted title inspection concurrency while preserving ordered results', async () => { + const entries = Array.from({ length: 12 }, (_, index) => { + const meta = header(`bounded-title-${index}`, index) + return { meta, events: eventLog(`title-${index}`) } + }) + TestPersistence.reset(entries) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + let active = 0 + let maximum = 0 + TestPersistence.inspectOverride = async (id) => { + active += 1 + maximum = Math.max(maximum, active) + await new Promise(resolve => setImmediate(resolve)) + active -= 1 + const entry = TestPersistence.entries.get(id) + if (entry === undefined) throw new Error('missing bounded test session') + return structuredClone(entry) + } + + const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id)) + + expect(maximum).toBe(4) + expect(TestPersistence.listCalls).toBe(1) + expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id)) + expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id)) + expect(results.every(result => result.status === 'fulfilled')).toBe(true) + }) + + it('folds and discards each completed log before its worker dequeues another inspection', async () => { + const entries = Array.from({ length: 5 }, (_, index) => ({ + meta: header(`project-title-${index}`, index), + events: [], + })) + TestPersistence.reset(entries) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const timeline: string[] = [] + const releases = new Map void>() + TestPersistence.inspectOverride = id => new Promise((resolve) => { + timeline.push(`inspect:${id}`) + releases.set(id, () => { + const marker = `full-log-marker:${id}` + const titleEvent = { + type: 'session/title', + seq: 1, + time: 20, + data: { + title: `Projected ${id}`, + get messageSeqs() { + timeline.push(`project:${id}`) + return [] + }, + source: { kind: 'fallback' }, + }, + } as unknown as SessionEvent + resolve({ + meta: entries.find(entry => entry.meta.id === id)!.meta, + events: [...eventLog(marker), titleEvent], + }) + }) + }) + const release = (id: SessionIdType): void => { + const settle = releases.get(id) + if (settle === undefined) throw new Error(`inspection ${id} has not started`) + settle() + } + const ids = entries.map(entry => entry.meta.id) + + const pending = ctx.sessionQuery.readTitleSnapshots(ids) + await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) }) + release(ids[0]!) + await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) }) + + // Heap-retention assertions would depend on nondeterministic GC. This ordering + // is the deterministic guard: a retain-all implementation cannot touch the + // observable title getter until every inspection has completed. + expect(timeline.indexOf(`project:${ids[0]}`)) + .toBeLessThan(timeline.indexOf(`inspect:${ids[4]}`)) + for (const id of ids.slice(1)) release(id) + const results = await pending + + expect(results.map(result => result.sessionId)).toEqual(ids) + expect(JSON.stringify(results)).not.toContain('full-log-marker:') + expect(results.every(result => result.status === 'fulfilled')).toBe(true) + }) + + it('passes cancellation into a stalled persisted title batch and rejects with its reason', async () => { + const persisted = header('stalled-title', 1) + TestPersistence.reset([{ meta: persisted, events: [] }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('title deadline') + let started!: () => void + const inspectStarted = new Promise((resolve) => { started = resolve }) + TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => { + started() + signal?.addEventListener('abort', () => { reject(reason) }, { once: true }) + }) + + const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal) + await inspectStarted + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + }) + + it('drains started title inspections after cancellation without starting queued ids', async () => { + const entries = Array.from({ length: 8 }, (_, index) => ({ + meta: header(`cancel-queued-title-${index}`, index), + events: eventLog(`queued-${index}`), + })) + TestPersistence.reset(entries) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('cancel queued title batch') + const releases: Array<() => void> = [] + let abortsObserved = 0 + let inspectionsSettled = 0 + TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true }) + releases.push(() => { + inspectionsSettled += 1 + reject(reason) + }) + }) + + const pending = ctx.sessionQuery.readTitleSnapshots( + entries.map(entry => entry.meta.id), + controller.signal, + ) + let batchSettled = false + void pending.then( + () => { batchSettled = true }, + () => { batchSettled = true }, + ) + await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) }) + controller.abort(reason) + await vi.waitFor(() => { expect(abortsObserved).toBe(4) }) + + expect(batchSettled).toBe(false) + expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id)) + for (const release of releases) release() + + await expect(pending).rejects.toBe(reason) + expect(inspectionsSettled).toBe(4) + expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id)) + }) + + it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => { + const persisted = header('stalled-title-list', 1) + TestPersistence.reset([{ meta: persisted, events: [] }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('title listing deadline') + let started!: () => void + const listStarted = new Promise((resolve) => { started = resolve }) + TestPersistence.listOverride = signal => new Promise((_resolve, reject) => { + started() + signal?.addEventListener('abort', () => { reject(reason) }, { once: true }) + }) + + const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal) + await listStarted + controller.abort(reason) + + await expect(pending).rejects.toBe(reason) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectCalls).toEqual([]) + }) + + it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => { + const attached = header('batch-title-attached', 1) + const failed = header('batch-title-failed', 2) + const malformed = header('batch-title-malformed', 3) + const inspectFailure = new Error('one title inspect failed') + const malformedTitle = { + type: 'session/title', + seq: 0, + time: 30, + data: { + title: 'malformed', + source: { kind: 'fallback' }, + }, + } as unknown as SessionEvent + TestPersistence.reset([ + { meta: attached, events: eventLog('stale persisted') }, + { meta: failed, events: [] }, + { meta: malformed, events: [malformedTitle] }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + TestPersistence.inspectOverride = (id) => { + if (id === failed.id) return Promise.reject(inspectFailure) + const entry = TestPersistence.entries.get(id) + if (entry === undefined) return Promise.reject(new Error('missing test session')) + if (id === attached.id) { + const session = ctx.sessions.create(attached.id, { meta: { createdAt: attached.createdAt } }) + session.append('session/title', { + title: 'Attached live title', + messageSeqs: [], + source: { kind: 'fallback' }, + }) + } + return Promise.resolve(structuredClone(entry)) + } + + const results = await ctx.sessionQuery.readTitleSnapshots([ + attached.id, + failed.id, + malformed.id, + ]) + + expect(results[0]).toMatchObject({ + status: 'fulfilled', + value: { session: attached, title: { title: 'Attached live title' } }, + }) + expect(results[1]).toMatchObject({ + sessionId: failed.id, + status: 'rejected', + reason: { + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + cause: inspectFailure, + }, + }) + expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' }) + if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection') + expect(results[2].reason).toBeInstanceOf(TypeError) + }) + + it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => { + const liveOnly = await liveContext() + const live = liveOnly.sessions.create(SessionId('batch-title-live')) + const missing = SessionId('batch-title-no-persistence') + + await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, live.id])).resolves.toEqual([{ + sessionId: live.id, + status: 'fulfilled', + value: { session: live.header }, + }]) + await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, missing])).resolves.toMatchObject([ + { sessionId: live.id, status: 'fulfilled' }, + { sessionId: missing, status: 'rejected' }, + ]) + await expect(liveOnly.sessionQuery.readTitleSnapshot(missing)) + .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) + + const persisted = header('batch-title-persisted', 1) + const late = header('batch-title-late', 2) + TestPersistence.reset([{ meta: persisted, events: [] }]) + const mixed = await liveContext() + const mixedLive = mixed.sessions.create(SessionId('batch-title-mixed-live')) + await mixed.plugin(TestPersistence) + TestPersistence.afterList = () => { + mixed.sessions.create(late.id, { meta: { createdAt: late.createdAt } }) + TestPersistence.afterList = undefined + } + + await expect(mixed.sessionQuery.readTitleSnapshots([ + mixedLive.id, + persisted.id, + late.id, + ])).resolves.toMatchObject([ + { sessionId: mixedLive.id, status: 'fulfilled' }, + { sessionId: persisted.id, status: 'fulfilled' }, + { sessionId: late.id, status: 'fulfilled' }, + ]) + + TestPersistence.reset() + TestPersistence.listFailure = new Error('title listing failed') + const failedList = await liveContext() + const survivingLive = failedList.sessions.create(SessionId('batch-title-list-live')) + await failedList.plugin(TestPersistence) + + await expect(failedList.sessionQuery.readTitleSnapshots([survivingLive.id, missing])) + .resolves.toMatchObject([ + { sessionId: survivingLive.id, status: 'fulfilled' }, + { + sessionId: missing, + status: 'rejected', + reason: expectCode('SESSION_QUERY_PERSISTENCE_FAILED'), + }, + ]) + }) + it('lists live sessions deterministically and returns detached headers', async () => { const ctx = await liveContext() const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } }) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index e7b8b1ef88..999fc1aa40 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -741,8 +741,16 @@ async function readTitles( signal: AbortSignal, ): Promise { const result = new Map() - for (const id of new Set(ids)) { - result.set(id, await readTitle(ctx, caller, id, signal)) + signal.throwIfAborted() + const observations = await ctx.sessionQuery.readTitleSnapshots(ids, signal) + signal.throwIfAborted() + for (const observation of observations) { + if (observation.status === 'rejected') { + result.set(observation.sessionId, unavailableTitle(ctx, observation.sessionId, observation.reason)) + continue + } + assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) + result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' }) } return result as CompleteTitleMap } @@ -753,19 +761,18 @@ async function readTitle( id: SessionIdValue, signal: AbortSignal, ): Promise { - signal.throwIfAborted() - try { - const observation = await ctx.sessionQuery.readTitleSnapshot(id) - signal.throwIfAborted() - assertObservedTargetAuthorized(caller, id, observation.session) - return { text: observation.title?.title ?? 'untitled' } - } catch (error: unknown) { - if (signal.aborted) signal.throwIfAborted() - if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error - const code = error instanceof HarnessError ? error.code : 'UNKNOWN' - ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) - return { text: 'untitled', unavailableCode: code } - } + return (await readTitles(ctx, caller, [id], signal)).get(id) +} + +function unavailableTitle( + ctx: Context, + id: SessionIdValue, + error: unknown, +): TitleView { + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error + const code = error instanceof HarnessError ? error.code : 'UNKNOWN' + ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) + return { text: 'untitled', unavailableCode: code } } function fullError(error: unknown): string { diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 8aa8376d67..c33b1c9967 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -21,6 +21,7 @@ import SessionQueryService, { type SessionSearchHit, type SessionSearchPage, type SessionSearchRequest, + type SessionTitleObservationResult, } from '@deepseek-ai/dsh-session-query' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' @@ -155,20 +156,31 @@ class FakeQuery extends SessionQueryService { return FakeQuery.eventSearch(request, exec) } - override async readTitleSnapshot(sessionId: SessionIdValue) { - const value = FakeQuery.titles.get(sessionId) - if (value instanceof Error) throw value - if (value === undefined) return super.readTitleSnapshot(sessionId) - return { - session: (await this.readSurface(sessionId)).session, - title: { - title: value, - messageSeqs: [], - source: { kind: 'fallback' as const }, - eventSeq: 0, - updatedAt: 1, - }, - } + override async readTitleSnapshots( + sessionIds: readonly SessionIdValue[], + signal?: AbortSignal, + ): Promise { + const observations = await super.readTitleSnapshots(sessionIds, signal) + return observations.map((observation): SessionTitleObservationResult => { + const value = FakeQuery.titles.get(observation.sessionId) + if (value instanceof Error) { + return { sessionId: observation.sessionId, status: 'rejected', reason: value } + } + if (value === undefined || observation.status === 'rejected') return observation + return { + ...observation, + value: { + ...observation.value, + title: { + title: value, + messageSeqs: [], + source: { kind: 'fallback' }, + eventSeq: 0, + updatedAt: 1, + }, + }, + } + }) } } @@ -494,9 +506,13 @@ describe('workspace authority and lineage redaction', () => { root: targetRecord, }) const titleReads: SessionIdValue[] = [] - vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation((sessionId) => { - titleReads.push(sessionId) - return Promise.resolve({ session: header(sessionId, '/work') }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((sessionIds) => { + titleReads.push(...sessionIds) + return Promise.resolve([...new Set(sessionIds)].map(sessionId => ({ + sessionId, + status: 'fulfilled' as const, + value: { session: header(sessionId, '/work') }, + }))) }) const output = text(await mounted.call('session_trace', { session_id: target.id })) @@ -596,16 +612,20 @@ describe('workspace authority and lineage redaction', () => { FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(target.id, '/work', 'safe hit')], }) - vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockResolvedValueOnce({ - session: movedHeader, - title: { - title: 'secret moved title', - messageSeqs: [], - source: { kind: 'fallback' }, - eventSeq: 0, - updatedAt: 1, + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ + sessionId: target.id, + status: 'fulfilled', + value: { + session: movedHeader, + title: { + title: 'secret moved title', + messageSeqs: [], + source: { kind: 'fallback' }, + eventSeq: 0, + updatedAt: 1, + }, }, - }) + }]) const titled = await mounted.call('session_search', { query: 'safe' }) expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') expect(text(titled)).not.toContain('secret moved title') @@ -828,9 +848,11 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const second = createSession(mounted.ctx, 'stackless-title', '/work') const stackless = new Error('stackless') Object.defineProperty(stackless, 'stack', { value: undefined }) - const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot') - .mockRejectedValueOnce('string failure') - .mockRejectedValueOnce(stackless) + const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots') + .mockResolvedValueOnce([ + { sessionId: first.id, status: 'rejected', reason: 'string failure' }, + { sessionId: second.id, status: 'rejected', reason: stackless }, + ]) FakeQuery.sessionSearch = () => Promise.resolve({ items: [ sessionHit(first.id, '/work'), @@ -840,7 +862,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) const result = await mounted.call('session_search', { query: 'needle' }) expect(text(result)).toContain('title unavailable: UNKNOWN') - expect(readTitle).toHaveBeenCalledTimes(2) + expect(readTitles).toHaveBeenCalledTimes(1) + expect(readTitles.mock.calls[0]?.[0]).toEqual([first.id, second.id]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless')) }) @@ -849,14 +872,43 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const mounted = await mount() const hit = createSession(mounted.ctx, 'abort-title', '/work') const controller = new AbortController() + const cancellation = new Error('cancelled title batch') FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) - vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(() => { - controller.abort() - return Promise.reject(new Error('cancelled title')) + let started!: () => void + const batchStarted = new Promise((resolve) => { started = resolve }) + const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation((_ids, signal) => { + started() + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { reject(cancellation) }, { once: true }) + }) }) - const result = await mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) + const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) + await batchStarted + controller.abort(cancellation) + const result = await pending expect(result.isError).toBe(true) expect(text(result)).not.toContain('title unavailable') + expect(readTitles.mock.calls[0]?.[1]).toBe(controller.signal) + }) + + it('does not downgrade an authorization failure returned by title observation', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'unauthorized-title-error', '/work') + const failure = new HarnessError( + 'title observation became unauthorized', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ + sessionId: hit.id, + status: 'rejected', + reason: failure, + }]) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(result)).not.toContain('title unavailable') }) it('passes the exact execution signal to every FTS page and stops on cancellation', async () => { @@ -907,9 +959,13 @@ describe('trace and exact read rendering', () => { complete: true, root: targetRecord, }) - vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(sessionId => Promise.resolve({ - session: header(sessionId, '/work'), - })) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockImplementation(sessionIds => Promise.resolve( + [...new Set(sessionIds)].map(sessionId => ({ + sessionId, + status: 'fulfilled' as const, + value: { session: header(sessionId, '/work') }, + })), + )) const output = text(await mounted.call('session_trace', { session_id: target.id })) expect(output).toContain('Descendants:\n- deep-1 —') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a68097864e..3fe3a681de 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -139,6 +139,7 @@ export const LINK_MAP: Record = { SessionSearchPage: 'session-query.md', SessionSearchRequest: 'session-query.md', SessionTitleObservation: 'session-query.md', + SessionTitleObservationResult: 'session-query.md', SessionTitleProvider: 'session-title.md', SessionTitleSnapshot: 'session-title.md', SkillDefinition: 'skills.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index ce9d54c217..2213725f7f 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -404,6 +404,11 @@ "symbol": "SessionTitleObservation", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionTitleObservationResult", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", From 0d2e74ed4875a1d6a72c17baa3d230c8415c20f6 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 18:22:00 +0800 Subject: [PATCH 11/19] docs: describe host session-query surface --- packages/host/runtime/README.md | 78 ++++++++++++++++++- .../verify-package-readme-model-experience.ts | 1 - 2 files changed, 74 insertions(+), 5 deletions(-) diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..15cff947df 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence and immediate fallback titles, optional first-message model summaries, system prompt, tools, agents, agent loop, workspace instructions, local bash, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, a derived SQLite FTS session-query index, immediate fallback titles, optional first-message model summaries, system prompt, tool and agent registries, agent loop, five workspace-authorized model-facing session-query tools, workspace instructions, local bash, the generic tool-timeout and 50,000-byte spill policies, and the provider-neutral user-interaction service), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`. Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -8,7 +8,7 @@ Which plugins mount and with what defaults is decided only here — shells must | Key | Default | Contract | |---|---:|---| -| `persistenceRoot` | (required) | Root directory for JSONL session persistence. | +| `persistenceRoot` | (required) | Root directory for JSONL session logs and the derived `session-query.db` SQLite FTS index. | | `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. | | `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. | | `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. | @@ -22,11 +22,81 @@ Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt ## Model Experience -Indirectly, through the non-blocking first-message title request owned by [`dsh-session-title-llm`](../../session-title/session-title-llm/README.md) when `sessionTitleLlm` is enabled, the provider/model defaults injected into created and resumed agents, the other model-facing plugins `bootHost` mounts, and the logged [workspace-instruction prefix](../../context/workspace-context/README.md#prompt-shape) when `workspaceContext` is enabled. +### Prior-history system prompt + +#### What the model sees + +Every main host agent receives the fixed prior-history guidance below because `bootHost` always mounts the session-query tool plugin. + +##### Prior-history guidance + +```markdown +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. +``` + +#### Token effect + +One fixed concise section is present on every request; `workspaceContext: false` does not remove it. #### KV Cache effect -No main-request invalidation; when enabled, the auxiliary title request has its own cache behavior and leaves the conversation prefix unchanged. +The repeated prefix is stable while the fixed host assembly and guidance text are unchanged. Provider cache availability and eviction remain outside the host contract. + +### Session-query tool schemas + +#### What the model sees + +The fixed assembly mounts the generated [`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query). The schemas expose no workspace path, provider cursor, output page, model-controlled result limit, or timeout argument. + +#### Token effect + +Five fixed read-only schemas are present on every main-agent request; their cost changes only if the host assembly or an agent-scoped visibility policy changes. + +#### KV Cache effect + +The schema prefix is stable while visibility, definitions, and order are unchanged. The host makes no claim that a provider will cache or retain that prefix. + +### Session-query execution and results + +#### What the model sees + +Cross-session results require exact equality with the calling session's workspace, while a caller without a workspace can target only itself. `session_search` excludes the calling session, and `session_event_search` on the current session excludes the step performing the call. Both searches are cursor-free, collect at most 100 authorized results, and carry a cooperative 30-second deadline; the three trace/read tools carry caller cancellation but declare no host deadline. Results are plain text. When a final result exceeds 50,000 UTF-8 bytes, the generic spill policy attempts to retain the complete formatted text in a private session-scoped file and replace it with a bounded preview, locator, and retrieval hint; a spill failure leaves the original result visible. + +#### Token effect + +Call arguments and data-dependent results remain in history until compaction. Search result count is bounded; after a successful spill, only the bounded preview and retrieval notice are resent, while the complete text remains outside model context. + +#### KV Cache effect + +Calls and results append after the reusable request prefix. Compaction may replace earlier history; timeout or spill outcomes change only the appended result text. + +### Workspace instructions + +#### What the model sees + +When `workspaceContext` is enabled, the model receives the logged [workspace-instruction prefix and loaded file contents](../../context/workspace-context/README.md#prompt-shape), bounded by that configuration's explicit `maxBytes`. Setting `workspaceContext: false` removes this surface. + +#### Token effect + +Disabled mode adds no tokens. Enabled mode adds the frozen data-dependent baseline to each request, up to the configured byte budget; later discovered, changed, or removed instructions append bounded history messages. + +#### KV Cache effect + +Prefix-stable within one loop instance because its baseline is frozen. A new or resumed instance recomposes the baseline, while touch-discovered changes during an instance append after the reusable prefix. + +### First-message title auxiliary request + +#### What the model sees + +When `sessionTitleLlm` is enabled, a separate [first-message title model](../../session-title/session-title-first-message-llm/README.md) receives the shared title instruction and a JSON array containing only the first eligible human message. It uses an explicitly configured route or inherits the exact logged main-request route; this auxiliary request does not add text to the main model request or delay its response. + +#### Token effect + +Disabled by default, so it normally adds no model request. With `sessionTitleLlm: true`, a fresh non-fork session makes at most one automatic request capped at 4,096 input bytes and 64 output tokens; an explicit configuration supplies its own caps. The deterministic fallback remains when the auxiliary request fails. + +#### KV Cache effect + +The main conversation prefix is unchanged. The auxiliary request has independent, provider-specific cache behavior; the host does not promise cache reuse. ## Known Limitations and Deferred Work diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..5687e51bac 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -66,7 +66,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' }, 'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' }, 'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' }, - 'packages/host/runtime': { kind: 'indirect', reason: 'The assembly mounts model-facing plugins and injects provider/model defaults into agents.' }, 'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' }, 'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' }, 'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' }, From 415948dd7b61414a1a7035ac4a42853efa6fa8db Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 18:47:41 +0800 Subject: [PATCH 12/19] fix: redact lineage errors and parse precise times --- .../tool-session-query/src/index.ts | 95 ++++++- .../tests/sqlite-integration.spec.ts | 128 ++++++++++ .../tests/tool-session-query.spec.ts | 234 ++++++++++++++++++ 3 files changed, 448 insertions(+), 9 deletions(-) diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 999fc1aa40..df1184ffed 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -428,7 +428,19 @@ async function executeSessionTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceSession(sessionId) + let trace: SessionLineageTrace + try { + trace = await ctx.sessionQuery.traceSession(sessionId) + } catch (error: unknown) { + exec.signal.throwIfAborted() + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { + throw new SessionQueryError( + 'session lineage is invalid', + 'SESSION_QUERY_INVALID_LINEAGE', + ) + } + throw error + } exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, trace.target.header) @@ -579,21 +591,31 @@ function timestampRange( to: string | undefined, ): { from?: number; to?: number } | undefined { if (from === undefined && to === undefined) return undefined - const fromMs = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) - const toMs = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) - if (fromMs !== undefined && toMs !== undefined && fromMs > toMs) { + const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) + const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) + if ( + fromTimestamp !== undefined + && toTimestamp !== undefined + && compareTimestamps(fromTimestamp, toTimestamp) > 0 + ) { throw invalidRange(name, 'from must be less than or equal to to') } return { - ...fromMs === undefined ? {} : { from: fromMs }, - ...toMs === undefined ? {} : { to: toMs }, + ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, + ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, } } const ISO_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ -function parseIsoTimestamp(name: string, value: string): number { +interface ExactTimestamp { + readonly millisecond: number + /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ + readonly remainder: string +} + +function parseIsoTimestamp(name: string, value: string): ExactTimestamp { const match = ISO_TIMESTAMP.exec(value) if (match === null) { throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') @@ -614,8 +636,63 @@ function parseIsoTimestamp(name: string, value: string): number { ) { throw invalidRange(name, 'must be a valid ISO 8601 timestamp') } - const timestamp = Date.parse(value) - return timestamp + const fraction = match[7] ?? '' + const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') + const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` + + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` + const timestamp = Date.parse(normalized) + if (!Number.isSafeInteger(timestamp)) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + return { + millisecond: timestamp, + remainder: fraction.slice(3).replace(/0+$/u, ''), + } +} + +function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { + if (left.millisecond !== right.millisecond) { + return left.millisecond < right.millisecond ? -1 : 1 + } + const length = Math.max(left.remainder.length, right.remainder.length) + for (let index = 0; index < length; index += 1) { + const leftDigit = left.remainder[index] ?? '0' + const rightDigit = right.remainder[index] ?? '0' + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function timestampLowerBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextUpFinite(timestamp.millisecond) +} + +function timestampUpperBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextDownFinite(timestamp.millisecond + 1) +} + +/** Return the adjacent IEEE-754 value toward positive infinity for a finite input. */ +function nextUpFinite(value: number): number { + if (value === 0) return Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) + return view.getFloat64(0) +} + +/** Return the adjacent IEEE-754 value toward negative infinity for a finite input. */ +function nextDownFinite(value: number): number { + if (value === 0) return -Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) + return view.getFloat64(0) } function daysInMonth(year: number, month: number): number { diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index fb85bdc309..fd7c07538b 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -97,4 +97,132 @@ describe('tool-session-query with the real SQLite provider', () => { expect(liveEvents.content.map(block => block.type === 'text' ? block.text : '').join('\n')) .toContain('seq 1') }) + + it('passes finite fractional epoch-millisecond bounds through SQLite comparisons', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-fractional-')) + temporaryDirectories.push(root) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + await ctx.plugin(SessionQuerySqlite, { path: join(root, 'session-query.db') }) + await ctx.plugin(ToolSessionQuery) + + const base = Date.parse('2026-07-24T00:00:00.000Z') + const persisted = SessionId('fractional-persisted') + await ctx.sessionPersistence.create({ + version: SESSION_FORMAT_VERSION, + id: persisted, + createdAt: base, + cwd: '/work', + }) + await ctx.sessionPersistence.append(persisted, [ + { + type: 'user/message', + seq: 0, + time: base + 123, + data: { + content: [{ type: 'text', text: 'fractional integration needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 1, + time: base + 124, + data: { + content: [{ type: 'text', text: 'fractional integration needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 2, + time: -124, + data: { + content: [{ type: 'text', text: 'pre-epoch fractional needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + { + type: 'user/message', + seq: 3, + time: -123, + data: { + content: [{ type: 'text', text: 'pre-epoch fractional needle' }], + source: { kind: 'user' }, + }, + surfaceOp: 'append', + }, + ]) + + const caller = ctx.sessions.create(SessionId('fractional-caller'), { + meta: { createdAt: base + 1_000, cwd: '/work' }, + }) + let call = 0 + const execute = (args: unknown) => ctx.tools.execute({ + name: 'session_event_search', + arguments: args, + callId: CallId(`fractional-integration-${++call}`), + signal: new AbortController().signal, + agent: fakeAgent(caller), + }) + + const lowerBound = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_from: '2026-07-24T00:00:00.12300001Z', + }) + expect(lowerBound.isError).toBe(false) + const lowerText = lowerBound.content.map(block => block.type === 'text' ? block.text : '').join('\n') + expect(lowerText).toContain('seq 1') + expect(lowerText).not.toContain('seq 0') + + const upperBound = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_to: '2026-07-24T08:00:00.1239999+08:00', + }) + expect(upperBound.isError).toBe(false) + const upperText = upperBound.content.map(block => block.type === 'text' ? block.text : '').join('\n') + expect(upperText).toContain('seq 0') + expect(upperText).not.toContain('seq 1') + + const emptySameMillisecond = await execute({ + session_id: persisted, + query: 'fractional integration needle', + time_from: '2026-07-24T00:00:00.12300001Z', + time_to: '2026-07-24T08:00:00.1239999+08:00', + }) + expect(emptySameMillisecond.isError).toBe(false) + expect(emptySameMillisecond.content.map(block => block.type === 'text' ? block.text : '').join('\n')) + .toContain('No prior event matches found.') + + const preEpochLower = await execute({ + session_id: persisted, + query: 'pre-epoch fractional needle', + time_from: '1969-12-31T23:59:59.87600001Z', + }) + expect(preEpochLower.isError).toBe(false) + const preEpochLowerText = preEpochLower.content + .map(block => block.type === 'text' ? block.text : '').join('\n') + expect(preEpochLowerText).toContain('seq 3') + expect(preEpochLowerText).not.toContain('seq 2') + + const preEpochUpper = await execute({ + session_id: persisted, + query: 'pre-epoch fractional needle', + time_to: '1969-12-31T19:59:59.8769999-04:00', + }) + expect(preEpochUpper.isError).toBe(false) + const preEpochUpperText = preEpochUpper.content + .map(block => block.type === 'text' ? block.text : '').join('\n') + expect(preEpochUpperText).toContain('seq 2') + expect(preEpochUpperText).not.toContain('seq 3') + }) }) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index c33b1c9967..2afb4e9dc7 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -382,6 +382,137 @@ describe('input validation and translation', () => { }) }) + it.each([ + ['one fractional digit', '2026-07-24T00:00:00.1Z', 100], + ['two fractional digits', '2026-07-24T00:00:00.12Z', 120], + ['three fractional digits', '2026-07-24T00:00:00.123Z', 123], + ])('normalizes %s into an exact integer epoch-millisecond filter', async (_case, value, offset) => { + const mounted = await mount() + await mounted.call('session_search', { + query: 'q', + created_at_from: value, + }) + const expected = Date.parse('2026-07-24T00:00:00.000Z') + offset + expect(Number.isFinite(expected)).toBe(true) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'created-at', + from: expected, + }) + }) + + it('maps exact same-millisecond decimal bounds to adjacent numeric values without collapsing the interval', async () => { + const mounted = await mount() + const base = Date.parse('2026-07-24T00:00:00.000Z') + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12300001Z', + created_at_to: '2026-07-24T08:00:00.1239999+08:00', + }) + + expect(result.isError).toBe(false) + expect(text(result)).toContain('No prior session matches found.') + const range = FakeQuery.sessionRequests[0]?.sessionFilters + ?.find(filter => filter.kind === 'created-at') + expect(range).toBeDefined() + if (range?.kind !== 'created-at' || range.from === undefined || range.to === undefined) { + throw new Error('expected complete created-at range') + } + expect(Number.isFinite(range.from)).toBe(true) + expect(Number.isFinite(range.to)).toBe(true) + expect(range.from).toBeGreaterThan(base + 123) + expect(range.from).toBeLessThan(base + 124) + expect(range.to).toBeGreaterThan(base + 123) + expect(range.to).toBeLessThan(base + 124) + expect(range.from).toBeLessThan(range.to) + }) + + it('rejects exact bounds reversed only below one millisecond before calling the provider', async () => { + const mounted = await mount() + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12300002Z', + created_at_to: '2026-07-24T00:00:00.12300001Z', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('compares unequal-length exact remainders with implicit trailing decimal zeroes', async () => { + const mounted = await mount() + const ordered = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.1231Z', + created_at_to: '2026-07-24T00:00:00.12311Z', + }) + expect(ordered.isError).toBe(false) + + const reversed = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.12311Z', + created_at_to: '2026-07-24T00:00:00.1231Z', + }) + expect(errorCode(reversed)).toBe('SESSION_QUERY_INVALID_FILTER') + }) + + it('treats trailing-zero fractional spellings as the same exact instant', async () => { + const mounted = await mount() + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.1230000100Z', + created_at_to: '2026-07-24T00:00:00.12300001Z', + }) + + expect(result.isError).toBe(false) + expect(FakeQuery.sessionRequests).toHaveLength(1) + }) + + it('maps fractional bounds correctly across zero and for negative pre-epoch milliseconds', async () => { + const mounted = await mount() + await mounted.call('session_search', { + query: 'q', + created_at_from: '1970-01-01T00:00:00.0000001Z', + event_time_to: '1969-12-31T23:59:59.9999999Z', + }) + expect(FakeQuery.sessionRequests[0]?.sessionFilters).toContainEqual({ + kind: 'created-at', + from: Number.MIN_VALUE, + }) + expect(FakeQuery.sessionRequests[0]?.eventFilters).toContainEqual({ + kind: 'time', + to: -Number.MIN_VALUE, + }) + + await mounted.call('session_event_search', { + query: 'q', + time_from: '1969-12-31T23:59:59.87600001Z', + time_to: '1969-12-31T19:59:59.8769999-04:00', + }) + const range = FakeQuery.eventRequests[0]?.filters?.find(filter => filter.kind === 'time') + expect(range).toBeDefined() + if (range?.kind !== 'time' || range.from === undefined || range.to === undefined) { + throw new Error('expected complete event time range') + } + expect(range.from).toBeGreaterThan(-124) + expect(range.from).toBeLessThan(-123) + expect(range.to).toBeGreaterThan(-124) + expect(range.to).toBeLessThan(-123) + expect(range.from).toBeLessThan(range.to) + }) + + it('rejects a normalized timestamp when the platform parser cannot produce a finite value', async () => { + const mounted = await mount() + vi.spyOn(Date, 'parse').mockReturnValueOnce(Number.NaN) + + const result = await mounted.call('session_search', { + query: 'q', + created_at_from: '2026-07-24T00:00:00.123456Z', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + it('compiles one-sided timestamps and independent root/parent clauses', async () => { const mounted = await mount() await mounted.call('session_search', { @@ -463,6 +594,109 @@ describe('workspace authority and lineage redaction', () => { expect(output).not.toContain('hidden-grandchild-secret') }) + it('sanitizes a real outside-workspace ancestor cycle before the lineage error reaches the model', async () => { + const mounted = await mount() + const hiddenA = SessionId('hidden-cycle-a-secret') + const hiddenB = SessionId('hidden-cycle-b-secret') + createSession(mounted.ctx, hiddenA, '/outside', 2, hiddenB) + createSession(mounted.ctx, hiddenB, '/outside', 3, hiddenA) + const target = createSession(mounted.ctx, 'visible-cycle-target', '/work', 4, hiddenA) + + const result = await mounted.call('session_trace', { session_id: target.id }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_LINEAGE') + expect(text(result)).toBe('Error: session lineage is invalid') + const presentation = JSON.stringify(result) + expect(presentation).not.toContain(hiddenA) + expect(presentation).not.toContain(hiddenB) + }) + + it.each([ + { + name: 'typed query error', + makeError: () => new SessionQueryError( + 'unrelated persistence failure', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ), + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'unrelated persistence failure', + }, + { + name: 'plain error', + makeError: () => new Error('unrelated plain trace failure'), + code: undefined, + message: 'unrelated plain trace failure', + }, + ])('preserves an unrelated $name from lineage tracing', async ({ makeError, code, message }) => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'trace-failure-target', '/work') + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockRejectedValueOnce(makeError()) + + const result = await mounted.call('session_trace', { session_id: target.id }) + + expect(errorCode(result)).toBe(code) + expect(text(result)).toBe(`Error: ${message}`) + }) + + it('preserves caller cancellation while a lineage trace is pending', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work') + const trace = await mounted.ctx.sessionQuery.traceSession(target.id) + let started!: () => void + const traceStarted = new Promise((resolve) => { started = resolve }) + let finish!: (value: typeof trace) => void + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => { + started() + return new Promise((resolve) => { finish = resolve }) + }) + const controller = new AbortController() + const cancellation = new SessionQueryError('lineage trace cancelled', 'SESSION_QUERY_ABORTED') + + const pending = mounted.call( + 'session_trace', + { session_id: target.id }, + { signal: controller.signal }, + ) + await traceStarted + controller.abort(cancellation) + finish(trace) + const result = await pending + + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: lineage trace cancelled') + }) + + it('gives caller cancellation precedence when a pending trace rejects with invalid lineage', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'cancelled-invalid-lineage-target', '/work') + let started!: () => void + const traceStarted = new Promise((resolve) => { started = resolve }) + let fail!: (error: SessionQueryError) => void + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockImplementation(() => { + started() + return new Promise((_resolve, reject) => { fail = reject }) + }) + const controller = new AbortController() + const cancellation = new SessionQueryError('lineage trace cancelled first', 'SESSION_QUERY_ABORTED') + + const pending = mounted.call( + 'session_trace', + { session_id: target.id }, + { signal: controller.signal }, + ) + await traceStarted + controller.abort(cancellation) + fail(new SessionQueryError( + 'session lineage contains a cycle at "hidden-race-secret"', + 'SESSION_QUERY_INVALID_LINEAGE', + )) + const result = await pending + + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: lineage trace cancelled first') + expect(JSON.stringify(result)).not.toContain('hidden-race-secret') + }) + it('renders branching descendants in source preorder with one indented marker per pruned subtree', async () => { const mounted = await mount() const target = createSession(mounted.ctx, 'branch-target', '/work', 20) From 795af3174ed2f06d3adf6d572bcc0945ae04435b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 19:21:32 +0800 Subject: [PATCH 13/19] fix: cancel session authorization reads --- docs/cordis-catalog/services.md | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 +- .../session-query/session-query/README.md | 4 +- .../session-query/session-query/src/corpus.ts | 8 +- .../session-query/session-query/src/index.ts | 20 ++- .../session-query/tests/session-query.spec.ts | 92 +++++++++++++ .../tool-session-query/package.json | 1 + .../tool-session-query/src/index.ts | 4 +- .../tests/tool-session-query.spec.ts | 123 +++++++++++++++++- pnpm-lock.yaml | 3 + 10 files changed, 250 insertions(+), 19 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c2669885a4..e31cd66147 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -994,9 +994,10 @@ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchE /** * List the complete logical corpus using live-preferred records. + * @param signal - optional cancellation for persistence listing. * @returns deterministic newest-first cloned session records. */ -listSessions(): Promise +listSessions(signal?: AbortSignal): Promise /** * Read and replay-validate one complete logical session log without making it live. @@ -1009,9 +1010,10 @@ async readSession(sessionId: SessionId): Promise /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. + * @param signal - optional cancellation for persistence listing. * @returns matching cloned records in deterministic newest-first order. */ -async filterSessions(filters: readonly SessionResultFilter[]): Promise +async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise /** * Fold the latest log-backed title from one live-preferred logical session. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 72785f4e69..c9e827ff4b 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -495,16 +495,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits and their target header from one indexed generation.\n */', }, { - signature: 'listSessions(): Promise', - jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */', + signature: 'listSessions(signal?: AbortSignal): Promise', + jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @param signal - optional cancellation for persistence listing.\n * @returns deterministic newest-first cloned session records.\n */', }, { signature: 'async readSession(sessionId: SessionId): Promise', jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */', }, { - signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise', - jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */', + signature: 'async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise', + jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @param signal - optional cancellation for persistence listing.\n * @returns matching cloned records in deterministic newest-first order.\n */', }, { signature: 'async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise', diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 8c74f96e3c..6bd0a1990f 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -4,9 +4,9 @@ ## Reads -- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. +- `listSessions(signal?)` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store. -- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. +- `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index ebf0f40bbc..523b38b3b9 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -52,11 +52,14 @@ export class SessionCorpus { /** * List the complete logical corpus with live precedence and cloned headers. + * @param signal - optional cancellation for persistence listing. * @returns records in deterministic newest-first order. */ - async listSessions(): Promise { + async listSessions(signal?: AbortSignal): Promise { + signal?.throwIfAborted() const persistence = this._persistence - const persisted = persistence === undefined ? [] : await listPersisted(persistence) + const persisted = persistence === undefined ? [] : await listPersisted(persistence, signal) + signal?.throwIfAborted() const records = new Map() for (const header of persisted) { records.set(header.id, { header: structuredClone(header), live: false, persisted: true }) @@ -239,6 +242,7 @@ async function listPersisted( try { return await persistence.list(signal) } catch (error: unknown) { + if (signal?.aborted) signal.throwIfAborted() throw new SessionQueryError( `session persistence listing failed: ${errorMessage(error)}`, 'SESSION_QUERY_PERSISTENCE_FAILED', diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 000eb83425..4da71b2a84 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -115,10 +115,11 @@ export abstract class SessionQueryService extends Service { /** * List the complete logical corpus using live-preferred records. + * @param signal - optional cancellation for persistence listing. * @returns deterministic newest-first cloned session records. */ - listSessions(): Promise { - return this._corpus.listSessions() + listSessions(signal?: AbortSignal): Promise { + return this._corpus.listSessions(signal) } /** @@ -139,11 +140,15 @@ export abstract class SessionQueryService extends Service { /** * Filter the complete logical corpus with provider-independent predicates. * @param filters - ANDed session metadata and availability clauses. + * @param signal - optional cancellation for persistence listing. * @returns matching cloned records in deterministic newest-first order. */ - async filterSessions(filters: readonly SessionResultFilter[]): Promise { + async filterSessions( + filters: readonly SessionResultFilter[], + signal?: AbortSignal, + ): Promise { const ownedFilters = materializeSessionResultFilters(filters) - return this._filterSessions(ownedFilters) + return this._filterSessions(ownedFilters, signal) } /** @@ -220,8 +225,11 @@ export abstract class SessionQueryService extends Service { return this._filterEvents(sessionId, ownedFilters) } - private async _filterSessions(filters: readonly SessionResultFilter[]): Promise { - return filterSessionResults(await this._corpus.listSessions(), filters) + private async _filterSessions( + filters: readonly SessionResultFilter[], + signal?: AbortSignal, + ): Promise { + return filterSessionResults(await this._corpus.listSessions(signal), filters) } private async _filterEvents( diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index f88d8be0f6..dfa209496d 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -130,6 +130,98 @@ function rejectUnknown(reason: unknown): Promise { }) } +const cancellableSessionListings = [ + { + name: 'listSessions', + run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.listSessions(signal), + }, + { + name: 'filterSessions', + run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.filterSessions([], signal), + }, +] as const + +describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { + it('preserves an exact pre-abort reason without entering persistence', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('session listing cancelled before start') + controller.abort(reason) + + await expect(run(ctx, controller.signal)).rejects.toBe(reason) + expect(TestPersistence.listCalls).toBe(0) + expect(TestPersistence.listSignals).toEqual([]) + }) + + it('forwards in-flight cancellation and waits for persistence cleanup before rejecting', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('session listing cancelled in flight') + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + TestPersistence.listOverride = async (signal) => { + if (signal === undefined) throw new Error('expected persistence listing signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + } + + const pending = run(ctx, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) + + it('preserves cancellation after a persistence implementation ignores the signal', async () => { + TestPersistence.reset() + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('session listing cancelled before persistence returned') + const started = Promise.withResolvers() + const listing = Promise.withResolvers() + TestPersistence.listOverride = (_signal) => { + started.resolve(undefined) + return listing.promise + } + + const pending = run(ctx, controller.signal) + await started.promise + controller.abort(reason) + listing.resolve([]) + + await expect(pending).rejects.toBe(reason) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + }) +}) + describe('session-query exact reads', () => { it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { const valid = header('valid-log', 2) diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 9438ddb1de..791a376cec 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -51,6 +51,7 @@ "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.7" } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index df1184ffed..f6cebc1376 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -312,7 +312,7 @@ async function authorizeTarget( const records = await ctx.sessionQuery.filterSessions([ { kind: 'id', values: [target] }, { kind: 'cwd', values: [cwd] }, - ]) + ], signal) signal.throwIfAborted() if (records.length !== 1) throw unauthorizedTarget() } @@ -805,7 +805,7 @@ async function authorizeSessionIds( const records = await ctx.sessionQuery.filterSessions([ { kind: 'id', values: other }, { kind: 'cwd', values: [cwd] }, - ]) + ], signal) signal.throwIfAborted() for (const record of records) authorized.add(record.header.id) return authorized diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 2afb4e9dc7..134430baee 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -2,7 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' -import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout' +import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, @@ -30,6 +31,7 @@ import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' const activeContexts: Context[] = [] afterEach(async () => { + vi.useRealTimers() vi.restoreAllMocks() for (const ctx of activeContexts.splice(0)) await ctx.fiber.dispose() FakeQuery.reset() @@ -194,12 +196,14 @@ interface Mounted { async function mount( config: ToolSessionQuery.Config = {}, callerCwd: string | null = '/work', + enforceTimeout = false, ): Promise { const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) + if (enforceTimeout) await ctx.plugin(TimeoutPolicy) await ctx.plugin(FakeQuery) const fiber = await ctx.plugin(ToolSessionQuery, config) const caller = createSession(ctx, 'caller', callerCwd ?? undefined, 10) @@ -1145,6 +1149,123 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).not.toContain('title unavailable') }) + it('forwards caller cancellation into direct-target authorization and waits for cleanup', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'stalled-direct-authorization', '/work') + const controller = new AbortController() + const cancellation = new SessionQueryError( + 'direct-target authorization cancelled', + 'SESSION_QUERY_ABORTED', + ) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + .mockImplementation(async (_filters, signal) => { + if (signal === undefined) throw new Error('expected authorization signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + }) + + const pending = mounted.call( + 'session_event_search', + { session_id: target.id, query: 'needle' }, + { signal: controller.signal }, + ) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(cancellation) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(filterSessions.mock.calls[0]?.[1]).toBe(controller.signal) + expect(controller.signal.reason).toBe(cancellation) + expect(FakeQuery.eventRequests).toEqual([]) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: direct-target authorization cancelled') + expect(FakeQuery.eventRequests).toEqual([]) + }) + + it('forwards the search deadline into parent authorization and times out only after cleanup', async () => { + vi.useFakeTimers() + const timeoutMs = 1_234 + const mounted = await mount({ searchTimeoutMs: timeoutMs }, '/work', true) + const parent = createSession(mounted.ctx, 'stalled-parent-authorization', '/work') + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [sessionHit('authorized-child', '/work', 'needle', parent.id)], + }) + const upstream = new AbortController() + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + let deadlineSignal: AbortSignal | undefined + const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + .mockImplementation(async (_filters, signal) => { + if (signal === undefined) throw new Error('expected authorization signal') + deadlineSignal = signal + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + }) + + const pending = mounted.call( + 'session_search', + { query: 'needle' }, + { signal: upstream.signal }, + ) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + await vi.advanceTimersByTimeAsync(timeoutMs) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(deadlineSignal).toBeDefined() + expect(deadlineSignal).not.toBe(upstream.signal) + expect(filterSessions.mock.calls[0]?.[1]).toBe(deadlineSignal) + expect(FakeQuery.searchSignals).toEqual([deadlineSignal]) + expect(deadlineSignal?.reason).toBeInstanceOf(TimeoutReason) + expect(deadlineSignal?.reason).toMatchObject({ code: 'TOOL_TIMEOUT', timeoutMs }) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('TOOL_TIMEOUT') + expect(text(result)).toBe(`Error: tool call timed out after ${timeoutMs}ms`) + }) + it('passes the exact execution signal to every FTS page and stops on cancellation', async () => { const mounted = await mount() const controller = new AbortController() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a48eda1877..89440f48df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2954,6 +2954,9 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../timeout/timeout-policy '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools From 66585635c860f6b13ebc08a5e717fcd049d89319 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 19:47:23 +0800 Subject: [PATCH 14/19] fix: serialize paginated session searches --- ...model-facing-session-query-tools.i18n.yaml | 4 +-- ...-07-24-model-facing-session-query-tools.md | 8 ++--- ...-24-model-facing-session-query-tools.zh.md | 8 ++--- docs/config-catalog.md | 4 ++- docs/cordis-catalog/services.md | 2 +- .../session-query-sqlite/README.md | 1 + .../session-query-sqlite/src/index.ts | 17 ++++++++++ .../session-query-sqlite/tests/sqlite.spec.ts | 26 ++++++++++++++ .../session-query/session-query/README.md | 3 +- .../session-query/session-query/src/config.ts | 5 +++ .../session-query/session-query/src/corpus.ts | 10 +++--- .../session-query/session-query/src/index.ts | 17 ++++++++-- .../session-query/tests/session-query.spec.ts | 34 +++++++++++++------ .../tool-session-query/README.md | 2 +- .../tool-session-query/src/index.ts | 2 -- .../tests/tool-session-query.spec.ts | 29 +++++++++++++--- 16 files changed, 133 insertions(+), 39 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 7425619403..5ec76b0d61 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 0551adc431388d6cdd94b8e03c2976020ec90de4 -2026-07-24-model-facing-session-query-tools.zh.md: f82c0fac52d63ac3c11f48ee2769cb9e9590317c +2026-07-24-model-facing-session-query-tools.md: 2f057292acac2c565e6b9dac61ed1e013b998550 +2026-07-24-model-facing-session-query-tools.zh.md: 6ccf60f39afc4021899df5c422ae455259c2ecc3 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 0551adc431..2f057292ac 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -24,11 +24,11 @@ The search tools expose prior work rather than the operation that is performing ## Cursor-free results and spill -Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. +Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. Because internal pages share generation-bound cursors, both search tools are exclusive in the agent-loop scheduler; the exact trace and read tools opt into parallel sibling execution because their observations tolerate intervening commits. Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. -Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most four persisted-inspection workers and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. +Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most the service's configured `persistedInspectConcurrency` workers, which defaults to four, and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. ## Host composition @@ -44,8 +44,8 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, cancellation, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences -Models gain provider-independent access to prior session work without receiving storage authority or continuation state. Search has a finite per-call work bound and may require a narrower query to reach matches beyond the first 100; complete traces and event payloads may become spill references instead of inline text. Exact string `cwd` equality favors a conservative security boundary over resolving symlink-equivalent paths. Custom compositions may mount the tool without spill, but then they explicitly accept complete inline trace and read results. +Models gain provider-independent access to prior session work without receiving storage authority or continuation state. Search has a finite per-call work bound and may require a narrower query to reach matches beyond the first 100; search calls cannot overlap siblings, while exact observations retain parallel scheduling. Complete traces and event payloads may become spill references instead of inline text. Exact string `cwd` equality favors a conservative security boundary over resolving symlink-equivalent paths. Custom compositions may mount the tool without spill, but then they explicitly accept complete inline trace and read results. diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index f82c0fac52..6ccf60f39a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -24,11 +24,11 @@ Status: implemented ## 无游标结果与 spill -两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。 +两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。 追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 -会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用 4 个持久化检查 worker,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 +会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 ## 宿主组合 @@ -44,8 +44,8 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、取消、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 -模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。 +模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9f2682c5c3..700cd49e71 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1019,6 +1019,8 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number + /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ + persistedInspectConcurrency?: number } /** Supported SQLite journal modes. */ @@ -1027,7 +1029,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:75`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e31cd66147..b15fc29ee9 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1091,7 +1091,7 @@ async readEvent(request: SessionEventReadRequest): Promise Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:76`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:81`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index a2c48a669f..693b1275f2 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -28,6 +28,7 @@ The database is disposable but reset is guarded: every recognized schema version | `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. | | `snippetChars` | `240` | Maximum snippet length in Unicode code points. | | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count for inherited `readEvent()`. | +| `persistedInspectConcurrency` | `4` | Maximum concurrent persisted-log inspections for inherited batch reads; must be a positive safe integer. | ## Tokenizer and limits diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index b3ff8feb07..3acf806646 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -15,6 +15,7 @@ import type { SessionPersistenceSnapshot, } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { + SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, SessionSearchCursor, @@ -87,6 +88,8 @@ export interface Config extends SessionQueryConfig { maxLimit?: number /** Maximum snippet length in Unicode code points. Defaults to 240. */ snippetChars?: number + /** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */ + persistedInspectConcurrency?: number } interface ResolvedConfig { @@ -96,6 +99,7 @@ interface ResolvedConfig { maxLimit: number snippetChars: number readWindowMax: number + persistedInspectConcurrency: number } interface ObservedSession { @@ -176,6 +180,11 @@ export class SessionQuerySqlite extends SessionQueryService { maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT), snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS), readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX), + persistedInspectConcurrency: z.number() + .step(1) + .min(1) + .max(Number.MAX_SAFE_INTEGER) + .default(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY), }) /** Validated and defaulted backend configuration. */ @@ -937,6 +946,8 @@ function resolveConfig(config: Config): ResolvedConfig { maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT, snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS, readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX, + persistedInspectConcurrency: config.persistedInspectConcurrency + ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, } if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) { throw invalidConfig('path must not be blank') @@ -947,6 +958,12 @@ function resolveConfig(config: Config): ResolvedConfig { if (!Number.isInteger(resolved.readWindowMax) || resolved.readWindowMax < 0) { throw invalidConfig('readWindowMax must be a non-negative integer') } + if ( + !Number.isSafeInteger(resolved.persistedInspectConcurrency) + || resolved.persistedInspectConcurrency < 1 + ) { + throw invalidConfig('persistedInspectConcurrency must be a positive safe integer') + } if (resolved.defaultLimit > resolved.maxLimit) { throw invalidConfig('defaultLimit must be less than or equal to maxLimit') } diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 71892159d5..b777ad8455 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -13,6 +13,7 @@ import SessionQuerySqlite, { SESSION_QUERY_SQLITE_SCHEMA_VERSION, } from '@deepseek-ai/dsh-session-query-sqlite' import { + SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, SessionQueryError, SessionSearchCursor, type SessionAvailability, @@ -167,6 +168,29 @@ async function liveContext(config: ConstructorParameters { + it('defaults and validates persisted inspection concurrency through its Cordis config', async () => { + const defaultCtx = await liveContext() + expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency) + .toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY) + + const configuredValue = 2 + const configured = new SessionQuerySqlite.Config({ + path: ':memory:', + persistedInspectConcurrency: configuredValue, + }) + expect(configured.persistedInspectConcurrency).toBe(configuredValue) + const configuredCtx = await liveContext(configured) + expect((configuredCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency) + .toBe(configuredValue) + + for (const persistedInspectConcurrency of [0, Number.MAX_SAFE_INTEGER + 1]) { + expect(() => new SessionQuerySqlite.Config({ + path: ':memory:', + persistedInspectConcurrency, + })).toThrow() + } + }) + it('searches two-character Unicode61 tokens in live-only sessions', async () => { const ctx = await liveContext({ path: ':memory:', snippetChars: 20 }) const session = ctx.sessions.create(SessionId('live'), { @@ -486,6 +510,8 @@ describe('SQLite session search', () => { { path: ':memory:', maxLimit: 1e100 }, { path: ':memory:', snippetChars: 0 }, { path: ':memory:', readWindowMax: -1 }, + { path: ':memory:', persistedInspectConcurrency: 0 }, + { path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 }, { path: ':memory:', defaultLimit: 3, maxLimit: 2 }, { path: ':memory:', journalMode: 'memory' }, ]) { diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 6bd0a1990f..82d32f5119 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -15,7 +15,7 @@ - `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. - `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most four workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction @@ -38,6 +38,7 @@ The package has no provider coordinator, fallback implementation, or standalone | Key | Default | Contract | |---|---:|---| | `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. | +| `persistedInspectConcurrency` | `4` | Maximum concurrent persisted-log inspections in one batch read; must be a positive safe integer. | ## Model Experience diff --git a/packages/session-query/session-query/src/config.ts b/packages/session-query/session-query/src/config.ts index 5b7ddffd90..714ef937df 100644 --- a/packages/session-query/session-query/src/config.ts +++ b/packages/session-query/session-query/src/config.ts @@ -5,10 +5,15 @@ import { HarnessError } from '@deepseek-ai/dsh-llm' /** Default maximum `before`/`after` raw-event window. */ export const SESSION_QUERY_READ_WINDOW_MAX = 50 +/** Default maximum number of concurrent persisted-log inspections in one batch read. */ +export const SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY = 4 + /** Backend-independent configuration inherited by every session-query implementation. */ export interface Config { /** Maximum accepted raw read context on either side. Defaults to 50. */ readWindowMax?: number + /** Maximum concurrent persisted-log inspections in one batch read. Defaults to 4. */ + persistedInspectConcurrency?: number } /** Stable machine-routable failure taxonomy for session reads, traces, and search. */ diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 523b38b3b9..5ed04a4808 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -28,15 +28,15 @@ export type LogicalProjectionResult = | { sessionId: SessionId; status: 'fulfilled'; value: Value } | { sessionId: SessionId; status: 'rejected'; reason: unknown } -/** Bound persisted observation fan-out for public batch title reads. */ -const PERSISTED_INSPECT_CONCURRENCY = 4 - /** Resolves a live-preferred corpus against the persistence service mounted now. */ export class SessionCorpus { private _persistence: SessionPersistence | undefined private readonly _optionalPersistenceFiber: Fiber - constructor(private readonly _ctx: Context) { + constructor( + private readonly _ctx: Context, + private readonly _persistedInspectConcurrency: number, + ) { this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { const service = childCtx.sessionPersistence this._persistence = service @@ -188,7 +188,7 @@ export class SessionCorpus { await resolvePersisted(unresolved[index] as SessionId) } } - const workerCount = Math.min(PERSISTED_INSPECT_CONCURRENCY, unresolved.length) + const workerCount = Math.min(this._persistedInspectConcurrency, unresolved.length) const settlements = await Promise.allSettled( Array.from({ length: workerCount }, () => worker()), ) diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 4da71b2a84..a16c8e9047 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -31,6 +31,7 @@ import type { SessionTitleObservationResult, } from './types.ts' import { + SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError, type Config, @@ -48,7 +49,11 @@ import * as tracing from './tracing.ts' export type * from './types.ts' export { SessionSearchCursor } from './cursor.ts' export type { Config, SessionQueryErrorCode } from './config.ts' -export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts' +export { + SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, + SESSION_QUERY_READ_WINDOW_MAX, + SessionQueryError, +} from './config.ts' export { extractSessionEventText } from './extraction.ts' export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts' export { @@ -88,7 +93,15 @@ export abstract class SessionQueryService extends Service { 'SESSION_QUERY_INVALID_CONFIG', ) } - this._corpus = new SessionCorpus(ctx) + const persistedInspectConcurrency = config.persistedInspectConcurrency + ?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY + if (!Number.isSafeInteger(persistedInspectConcurrency) || persistedInspectConcurrency < 1) { + throw new SessionQueryError( + 'session-query: persistedInspectConcurrency must be a positive safe integer', + 'SESSION_QUERY_INVALID_CONFIG', + ) + } + this._corpus = new SessionCorpus(ctx, persistedInspectConcurrency) } /** diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index dfa209496d..4d093360ab 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -4,6 +4,7 @@ import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/ds import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' import SessionQueryService, { + SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY, type SessionEventSurface, type SessionQueryErrorCode, } from '@deepseek-ai/dsh-session-query' @@ -372,7 +373,7 @@ describe('session-query exact reads', () => { const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id)) - expect(maximum).toBe(4) + expect(maximum).toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY) expect(TestPersistence.listCalls).toBe(1) expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id)) expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id)) @@ -466,7 +467,8 @@ describe('session-query exact reads', () => { events: eventLog(`queued-${index}`), })) TestPersistence.reset(entries) - const ctx = await liveContext() + const persistedInspectConcurrency = 2 + const ctx = await liveContext({ persistedInspectConcurrency }) await ctx.plugin(TestPersistence) const controller = new AbortController() const reason = new Error('cancel queued title batch') @@ -490,17 +492,21 @@ describe('session-query exact reads', () => { () => { batchSettled = true }, () => { batchSettled = true }, ) - await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) }) + await vi.waitFor(() => { + expect(TestPersistence.inspectCalls).toHaveLength(persistedInspectConcurrency) + }) controller.abort(reason) - await vi.waitFor(() => { expect(abortsObserved).toBe(4) }) + await vi.waitFor(() => { expect(abortsObserved).toBe(persistedInspectConcurrency) }) expect(batchSettled).toBe(false) - expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id)) + expect(TestPersistence.inspectCalls) + .toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id)) for (const release of releases) release() await expect(pending).rejects.toBe(reason) - expect(inspectionsSettled).toBe(4) - expect(TestPersistence.inspectCalls).toEqual(entries.slice(0, 4).map(entry => entry.meta.id)) + expect(inspectionsSettled).toBe(persistedInspectConcurrency) + expect(TestPersistence.inspectCalls) + .toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id)) }) it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => { @@ -907,10 +913,16 @@ describe('session-query exact reads', () => { const direct = new Context() await direct.plugin(SessionStore) expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService) - const invalid = new Context() - await invalid.plugin(SessionStore) - expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 })) - .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + for (const config of [ + { readWindowMax: -1 }, + { persistedInspectConcurrency: 0 }, + { persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 }, + ]) { + const invalid = new Context() + await invalid.plugin(SessionStore) + expect(() => new TestSessionQueryService(invalid, config)) + .toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG')) + } }) it('leaves the optional persistence dependency optional', async () => { diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 2a5ad72f9c..f9e466f4a0 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -9,7 +9,7 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on | `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages | | `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools | -The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. +The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. `session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index f6cebc1376..186e2ca75c 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -211,7 +211,6 @@ export function apply(ctx: Context, config: Config): void { parameters: SESSION_SEARCH_PARAMETERS, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - isConcurrencySafe: () => true, execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), presentCall: presentSessionSearchCall, })) @@ -222,7 +221,6 @@ export function apply(ctx: Context, config: Config): void { parameters: EVENT_SEARCH_PARAMETERS, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - isConcurrencySafe: () => true, execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults), presentCall: presentEventSearchCall, })) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 134430baee..f403b3f5ba 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -248,15 +248,13 @@ describe('registration and schemas', () => { expect(sessionSchema?.parameters).not.toHaveProperty('properties.cwd') expect(mounted.ctx.tools.get('session_search')?.timeoutMs).toBe(1234) expect(mounted.ctx.tools.get('session_trace')?.timeoutMs).toBeUndefined() - const safeArgs: Record = { - session_search: { query: 'q' }, - session_event_search: { query: 'q' }, + const parallelArgs: Record = { session_trace: {}, session_event_trace: { seq: 0 }, session_event_read: { seq: 0 }, } - for (const name of names) { - expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(safeArgs[name])).toBe(true) + for (const [name, args] of Object.entries(parallelArgs)) { + expect(mounted.ctx.tools.get(name)?.isConcurrencySafe?.(args)).toBe(true) } expect(mounted.ctx.tools.get('session_search')?.output.render({}, 'rendered')) .toEqual([{ type: 'text', text: 'rendered' }]) @@ -287,6 +285,27 @@ describe('registration and schemas', () => { .not.toContain('tool:session-query') }) + it('keeps generation-bound searches exclusive while exact observations remain parallel', async () => { + const mounted = await mount() + const classifications = [ + ['session_search', { query: 'q' }, 'exclusive'], + ['session_event_search', { query: 'q' }, 'exclusive'], + ['session_trace', {}, 'parallel'], + ['session_event_trace', { seq: 0 }, 'parallel'], + ['session_event_read', { seq: 0 }, 'parallel'], + ] as const + + for (const [name, args, kind] of classifications) { + expect(mounted.ctx.tools.executionMode({ + name, + arguments: args, + callId: CallId(`mode-${name}`), + signal: new AbortController().signal, + agent: fakeAgent(mounted.caller), + })).toEqual({ kind }) + } + }) + it('fails invalid direct config before registering anything', async () => { const mounted = await mount() for (const maxSearchResults of [0, 1.5, Number.NaN]) { From 04c8a17de5e48cd3b5f4c3d485baf86323625733 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 20:08:38 +0800 Subject: [PATCH 15/19] fix: cancel exact session observations --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 4 +- ...-24-model-facing-session-query-tools.zh.md | 4 +- docs/cordis-catalog/services.md | 9 +- .../cordis/tool-cordis/src/api-catalog.ts | 12 +- .../session-query/session-query/README.md | 8 +- .../session-query/session-query/src/corpus.ts | 26 ++- .../session-query/session-query/src/index.ts | 21 +- .../session-query/tests/session-query.spec.ts | 197 +++++++++++++++++- .../tool-session-query/README.md | 2 +- .../tool-session-query/src/index.ts | 6 +- .../tests/tool-session-query.spec.ts | 72 +++++++ 12 files changed, 328 insertions(+), 37 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 5ec76b0d61..cc975b746d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 2f057292acac2c565e6b9dac61ed1e013b998550 -2026-07-24-model-facing-session-query-tools.zh.md: 6ccf60f39afc4021899df5c422ae455259c2ecc3 +2026-07-24-model-facing-session-query-tools.md: 75aa8eef1b27ca64b49710ba055b33d091f514ad +2026-07-24-model-facing-session-query-tools.zh.md: a59f88b38ac88b9a88cb55e799847c283145205a diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 2f057292ac..75aa8eef1b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -26,7 +26,7 @@ The search tools expose prior work rather than the operation that is performing Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. Because internal pages share generation-bound cursors, both search tools are exclusive in the agent-loop scheduler; the exact trace and read tools opt into parallel sibling execution because their observations tolerate intervening commits. -Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. +Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. Each exact executor passes its unchanged tool-execution signal through target authorization and the service trace or read. Within service resolution, known-live event traces, event reads, and title reads remain persistence-free while honoring pre-abort. Session lineage tracing passes the signal to whole-corpus persistence listing; persisted event tracing and reading pass it to target listing and inspection. Each started backend call is awaited for cleanup before the exact abort reason is preserved, even when that backend ignored cancellation. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most the service's configured `persistedInspectConcurrency` workers, which defaults to four, and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, cancellation, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 6ccf60f39a..a59f88b38a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -26,7 +26,7 @@ Status: implemented 两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。 -追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 +追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、取消、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b15fc29ee9..4348fb4d39 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1068,25 +1068,28 @@ async readSurface(sessionId: SessionId): Promise /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. + * @param signal - optional cancellation for persistence listing. * @returns a complete lineage or an explicit unresolved parent boundary. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ -async traceSession(sessionId: SessionId): Promise +async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. + * @param signal - optional cancellation for persisted source resolution. * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ -async traceEvent(request: SessionEventTraceRequest): Promise +async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. + * @param signal - optional cancellation for persisted source resolution. * @returns cloned target and neighboring events. */ -async readEvent(request: SessionEventReadRequest): Promise +async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c9e827ff4b..4c0dce90cf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -531,16 +531,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */', }, { - signature: 'async traceSession(sessionId: SessionId): Promise', - jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { - signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', - jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', + signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', }, { - signature: 'async readEvent(request: SessionEventReadRequest): Promise', - jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @returns cloned target and neighboring events.\n */', + signature: 'async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns cloned target and neighboring events.\n */', }, ], }, diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 82d32f5119..27191c3e5f 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,11 +11,11 @@ - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. -- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. -- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. +- `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. +- `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 5ed04a4808..649a80965a 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -82,23 +82,37 @@ export class SessionCorpus { * A known live target never consults persistence, so an optional backend's * failure cannot make current in-memory history unreadable. * @param sessionId - session to resolve. + * @param signal - optional cancellation for persisted source resolution. * @returns detached live-preferred header and events. */ - async load(sessionId: SessionId): Promise { + async load(sessionId: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const live = this._ctx.sessions.get(sessionId) - if (live !== undefined) return snapshotLive(live) + if (live !== undefined) { + const snapshot = snapshotLive(live) + signal?.throwIfAborted() + return snapshot + } const persistence = this._persistence if (persistence === undefined) throw notFound(sessionId) - const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) + const listed = (await listPersisted(persistence, signal)).find(header => header.id === sessionId) + signal?.throwIfAborted() if (listed === undefined) throw notFound(sessionId) - const loaded = await inspectPersisted(persistence, sessionId) + const loaded = await inspectPersisted(persistence, sessionId, signal) + signal?.throwIfAborted() const attached = this._ctx.sessions.get(sessionId) - if (attached !== undefined) return snapshotLive(attached) + if (attached !== undefined) { + const snapshot = snapshotLive(attached) + signal?.throwIfAborted() + return snapshot + } assertSessionHeadersCompatible(loaded.meta, listed) - return { + const snapshot = { header: structuredClone(loaded.meta), events: loaded.events.map(event => structuredClone(event)), } + signal?.throwIfAborted() + return snapshot } /** diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index a16c8e9047..4dc982f8f3 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -272,22 +272,26 @@ export abstract class SessionQueryService extends Service { /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. + * @param signal - optional cancellation for persistence listing. * @returns a complete lineage or an explicit unresolved parent boundary. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ - async traceSession(sessionId: SessionId): Promise { - const records = await this._corpus.listSessions() + async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise { + const records = await this._corpus.listSessions(signal) + signal?.throwIfAborted() return tracing.traceSession(records, sessionId) } /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. + * @param signal - optional cancellation for persisted source resolution. * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ - async traceEvent(request: SessionEventTraceRequest): Promise { - const loaded = await this._corpus.load(request.sessionId) + async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise { + const loaded = await this._corpus.load(request.sessionId, signal) + signal?.throwIfAborted() return { session: loaded.header, ...tracing.traceEvent(request.sessionId, loaded.events, request.seq), @@ -297,14 +301,15 @@ export abstract class SessionQueryService extends Service { /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. + * @param signal - optional cancellation for persisted source resolution. * @returns cloned target and neighboring events. */ - async readEvent(request: SessionEventReadRequest): Promise { + async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) const sessionId = request.sessionId const seq = request.seq - return this._readEvent(sessionId, seq, before, after) + return this._readEvent(sessionId, seq, before, after, signal) } private async _readEvent( @@ -312,8 +317,10 @@ export abstract class SessionQueryService extends Service { seq: number, before: number, after: number, + signal?: AbortSignal, ): Promise { - const loaded = await this._corpus.load(sessionId) + const loaded = await this._corpus.load(sessionId, signal) + signal?.throwIfAborted() const target = loaded.events[seq] if (target === undefined || target.seq !== seq) { throw new SessionQueryError( diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 4d093360ab..b830158be7 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -142,6 +142,34 @@ const cancellableSessionListings = [ }, ] as const +interface CancellableExactRead { + readonly name: 'traceSession' | 'traceEvent' | 'readEvent' + readonly inspects: boolean + readonly run: ( + ctx: Context, + sessionId: SessionIdType, + signal: AbortSignal, + ) => Promise +} + +const cancellableExactReads: readonly CancellableExactRead[] = [ + { + name: 'traceSession', + inspects: false, + run: (ctx, sessionId, signal) => ctx.sessionQuery.traceSession(sessionId, signal), + }, + { + name: 'traceEvent', + inspects: true, + run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal), + }, + { + name: 'readEvent', + inspects: true, + run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal), + }, +] as const + describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { it('preserves an exact pre-abort reason without entering persistence', async () => { TestPersistence.reset() @@ -223,6 +251,167 @@ describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { }) }) +describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) => { + it('preserves an exact pre-abort reason without entering persistence', async () => { + const persisted = header('pre-aborted-exact-read') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read cancelled before start') + controller.abort(reason) + + await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason) + expect(TestPersistence.listCalls).toBe(0) + expect(TestPersistence.inspectCalls).toEqual([]) + }) + + it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => { + const persisted = header('cancelled-exact-list') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read list cancelled in flight') + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + TestPersistence.listOverride = async (signal) => { + if (signal === undefined) throw new Error('expected exact-read listing signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectCalls).toEqual([]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) + + it('waits for an ignoring backend to return before preserving the abort reason', async () => { + const persisted = header('ignored-exact-signal') + const entry = { meta: persisted, events: eventLog() } + TestPersistence.reset([entry]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read cancelled while backend ignored signal') + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let active = false + if (inspects) { + TestPersistence.inspectOverride = async () => { + active = true + started.resolve(undefined) + await release.promise + active = false + return structuredClone(entry) + } + } else { + TestPersistence.listOverride = async () => { + active = true + started.resolve(undefined) + await release.promise + active = false + return [structuredClone(persisted)] + } + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : []) + + release.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) +}) + +describe.each(cancellableExactReads.filter(read => read.inspects))( + '$name persisted inspection cancellation', + ({ run }) => { + it('forwards cancellation and waits for inspection cleanup before rejecting', async () => { + const persisted = header('cancelled-exact-inspect') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read inspection cancelled in flight') + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + TestPersistence.inspectOverride = async (_sessionId, signal) => { + if (signal === undefined) throw new Error('expected exact-read inspection signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + throw new Error('unreachable after exact-read cancellation') + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) + }, +) + describe('session-query exact reads', () => { it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { const valid = header('valid-log', 2) @@ -862,9 +1051,15 @@ describe('session-query exact reads', () => { await ctx.plugin(TestPersistence) TestPersistence.listFailure = new Error('list unavailable') TestPersistence.inspectFailure = new Error('inspect unavailable') + const signal = new AbortController().signal await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) - await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal)) + .resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } }) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal)) + .resolves.toMatchObject({ target: { seq: 1 } }) + expect(TestPersistence.listSignals).toEqual([]) + expect(TestPersistence.inspectSignals).toEqual([]) await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index f9e466f4a0..7b40527f62 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -9,7 +9,7 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on | `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages | | `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools | -The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. +The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. `session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 186e2ca75c..0ffbeeb836 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -428,7 +428,7 @@ async function executeSessionTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) let trace: SessionLineageTrace try { - trace = await ctx.sessionQuery.traceSession(sessionId) + trace = await ctx.sessionQuery.traceSession(sessionId, exec.signal) } catch (error: unknown) { exec.signal.throwIfAborted() if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { @@ -471,7 +471,7 @@ async function executeEventTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }) + const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal) exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, trace.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) @@ -494,7 +494,7 @@ async function executeEventRead( seq: args.seq, ...args.before === undefined ? {} : { before: args.before }, ...args.after === undefined ? {} : { after: args.after }, - }) + }, exec.signal) exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, window.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index f403b3f5ba..d52a4d48aa 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -661,6 +661,78 @@ describe('workspace authority and lineage redaction', () => { expect(text(result)).toBe(`Error: ${message}`) }) + it.each([ + 'session_trace', + 'session_event_trace', + 'session_event_read', + ] as const)('forwards the exact signal to %s and waits for service cleanup', async (toolName) => { + const mounted = await mount() + const target = createSession(mounted.ctx, `cancelled-${toolName}`, '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const controller = new AbortController() + const cancellation = new SessionQueryError( + `${toolName} cancelled`, + 'SESSION_QUERY_ABORTED', + ) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let observedSignal: AbortSignal | undefined + let active = false + const holdExactRead = async (signal?: AbortSignal): Promise => { + if (signal === undefined) throw new Error('expected exact tool execution signal') + observedSignal = signal + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + throw new Error('unreachable after exact tool cancellation') + } + if (toolName === 'session_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession') + .mockImplementation((_sessionId, signal) => holdExactRead(signal)) + } else if (toolName === 'session_event_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent') + .mockImplementation((_request, signal) => holdExactRead(signal)) + } else { + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent') + .mockImplementation((_request, signal) => holdExactRead(signal)) + } + const args = toolName === 'session_trace' + ? { session_id: target.id } + : { session_id: target.id, seq: 0 } + + const pending = mounted.call(toolName, args, { signal: controller.signal }) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(cancellation) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(observedSignal).toBe(controller.signal) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe(`Error: ${toolName} cancelled`) + }) + it('preserves caller cancellation while a lineage trace is pending', async () => { const mounted = await mount() const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work') From ba6fc1cfa3a17d0bfe92e214d8ddfd953ce94355 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 20:50:53 +0800 Subject: [PATCH 16/19] fix: harden session query authorization --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 6 +- ...-24-model-facing-session-query-tools.zh.md | 6 +- docs/config-catalog.md | 2 +- .../tool-session-query/README.md | 4 +- .../tool-session-query/src/index.ts | 296 +++++++--- .../tests/tool-session-query.spec.ts | 539 +++++++++++++++++- 7 files changed, 758 insertions(+), 99 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index cc975b746d..88363d0aca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 75aa8eef1b27ca64b49710ba055b33d091f514ad -2026-07-24-model-facing-session-query-tools.zh.md: a59f88b38ac88b9a88cb55e799847c283145205a +2026-07-24-model-facing-session-query-tools.md: aea490f3569dd95bffb6ebbaae5a130e6440c281 +2026-07-24-model-facing-session-query-tools.zh.md: eae100375fe7abf91ba3e503808a6d98b540255e diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 75aa8eef1b..aea490f356 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -14,12 +14,14 @@ The unified `ctx.sessionQuery` service exposes exact reads, filters, relationshi `session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. -Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Parent ids and the root-session marker share one parent clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. +Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. ## Workspace authority Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. +Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. It checks the execution signal first, preserving caller cancellation exactly. For other failures it records the available corpus or provider diagnostic chain in the internal log on a best-effort basis, substituting a fixed placeholder when the value cannot be safely inspected. Diagnostic formatting and error classification are independently guarded, so an unprintable nested cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging returns the fixed generic `SESSION_QUERY_TOOL_FAILED` code and message. Per-title failures use the same sanitizer before becoming unavailable markers. Tool-owned input-validation and authorization errors remain precise because they are created outside this service boundary. + The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. ## Cursor-free results and spill @@ -44,7 +46,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index a59f88b38a..eae100375f 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -14,12 +14,14 @@ Status: implemented `session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 -面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。父会话 id 与根会话标记共用一个父级条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 +面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 ## 工作区权限 每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 +每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。 + 搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 ## 无游标结果与 spill @@ -44,7 +46,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 700cd49e71..88d8f1bbaa 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1439,7 +1439,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:51`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:52`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 7b40527f62..504405d698 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -11,7 +11,9 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. -`session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. +`session_search` always omits the caller session. Requested parent ids are deduplicated and checked against caller-workspace authority before FTS; only authorized ids reach the provider, while missing and cross-workspace guesses behave identically and the root marker remains independently ORed. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. + +Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. Caller cancellation is checked first and preserved exactly. Available corpus and provider diagnostics, including safely inspectable nested causes, are logged internally on a best-effort basis; unprintable failures use a fixed log placeholder. Diagnostic formatting and error classification are independently guarded, so an unprintable cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging falls back to the fixed `SESSION_QUERY_TOOL_FAILED` code and message. Local argument-validation and authorization errors retain their precise tool-owned messages. The package deliberately performs no byte or character truncation and does not import a spill backend. Deployments that need bounded inline output mount `@deepseek-ai/dsh-spill-policy`, which can replace the rendered text after execution while retaining the complete result. diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 0ffbeeb836..e05ab89218 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -29,6 +29,7 @@ import { type SessionLineageTrace, type SessionRecord, type SessionResultFilter, + type SessionQueryErrorCode, type SessionSearchCursor, type SessionSearchHit, } from '@deepseek-ai/dsh-session-query' @@ -196,6 +197,76 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' +interface ModelSafeServiceFailure { + readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' + readonly message: string +} + +const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' + +const SAFE_SESSION_QUERY_FAILURES = { + SESSION_QUERY_ABORTED: { + code: 'SESSION_QUERY_ABORTED', + message: 'session query was cancelled', + }, + SESSION_QUERY_EVENT_NOT_FOUND: { + code: 'SESSION_QUERY_EVENT_NOT_FOUND', + message: 'session event was not found', + }, + SESSION_QUERY_INDEX_FAILED: { + code: 'SESSION_QUERY_INDEX_FAILED', + message: 'session search index is unavailable', + }, + SESSION_QUERY_INVALID_CONFIG: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, + SESSION_QUERY_INVALID_CURSOR: { + code: 'SESSION_QUERY_INVALID_CURSOR', + message: 'session search continuation is invalid', + }, + SESSION_QUERY_INVALID_FILTER: { + code: 'SESSION_QUERY_INVALID_FILTER', + message: 'session query filters were rejected', + }, + SESSION_QUERY_INVALID_LIMIT: { + code: 'SESSION_QUERY_INVALID_LIMIT', + message: 'session query result limit was rejected', + }, + SESSION_QUERY_INVALID_QUERY: { + code: 'SESSION_QUERY_INVALID_QUERY', + message: 'session query was rejected', + }, + SESSION_QUERY_INVALID_LINEAGE: { + code: 'SESSION_QUERY_INVALID_LINEAGE', + message: 'session lineage is invalid', + }, + SESSION_QUERY_INVALID_SURFACE: { + code: 'SESSION_QUERY_INVALID_SURFACE', + message: 'session event history is invalid', + }, + SESSION_QUERY_INVALID_WINDOW: { + code: 'SESSION_QUERY_INVALID_WINDOW', + message: 'session event window is invalid', + }, + SESSION_QUERY_PERSISTENCE_FAILED: { + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'session history storage is unavailable', + }, + SESSION_QUERY_SESSION_NOT_FOUND: { + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + message: 'session was not found', + }, + SESSION_QUERY_STALE_CURSOR: { + code: 'SESSION_QUERY_STALE_CURSOR', + message: 'session history changed while paging; retry the complete search call', + }, + SESSION_QUERY_SOURCE_CONFLICT: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, +} satisfies Record + /** Register all five tools and their shared model guidance. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) @@ -306,12 +377,11 @@ async function authorizeTarget( if (target === caller.id) return const cwd = caller.header.cwd if (cwd === undefined) throw unauthorizedTarget() - signal.throwIfAborted() - const records = await ctx.sessionQuery.filterSessions([ - { kind: 'id', values: [target] }, - { kind: 'cwd', values: [cwd] }, - ], signal) - signal.throwIfAborted() + const records = await sessionQueryCall(ctx, signal, 'target authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: [target] }, + { kind: 'cwd', values: [cwd] }, + ], signal)) if (records.length !== 1) throw unauthorizedTarget() } @@ -322,6 +392,57 @@ function unauthorizedTarget(): HarnessError { ) } +async function sessionQueryCall( + ctx: Context, + signal: AbortSignal, + operation: string, + call: () => Promise, +): Promise { + signal.throwIfAborted() + try { + const value = await call() + signal.throwIfAborted() + return value + } catch (error: unknown) { + signal.throwIfAborted() + throw sanitizeSessionQueryError(ctx, operation, error) + } +} + +function sanitizeSessionQueryError( + ctx: Context, + operation: string, + error: unknown, +): HarnessError { + const generic = genericSessionQueryFailure() + const diagnostic = fullError(error) + try { + ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) + if (error instanceof SessionQueryError) { + const code: unknown = error.code + const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) + ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] + : undefined + if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { + return new SessionQueryError(failure.message, failure.code) + } + } + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { + return unauthorizedTarget() + } + } catch { + return generic + } + return generic +} + +function genericSessionQueryFailure(): HarnessError { + return new HarnessError( + 'session query operation failed', + 'SESSION_QUERY_TOOL_FAILED', + ) +} + async function executeSessionSearch( ctx: Context, args: SessionSearchArgs, @@ -338,7 +459,6 @@ async function executeSessionSearch( } const query = normalizeQuery(args.query) const sessionFilters = buildSessionFilters(args) - sessionFilters.push({ kind: 'cwd', values: [cwd] }) const eventFilters = buildEventFilters({ seqFrom: args.event_seq_from, seqTo: args.event_seq_to, @@ -347,15 +467,28 @@ async function executeSessionSearch( eventTypes: args.event_types, surfaces: args.event_surfaces, }) + const requestedParentIds = materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) const collected = await collectPages( maxResults, exec.signal, - cursor => ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }), + cursor => sessionQueryCall(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), hit => hit.header.id !== caller.id && recordAuthorized(hit, caller), ) @@ -404,12 +537,13 @@ async function executeEventSearch( maxResults, exec.signal, async (cursor): Promise => { - const page = await ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }) + const page = await sessionQueryCall(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) assertObservedTargetAuthorized(caller, sessionId, page.session) return page }, @@ -426,20 +560,8 @@ async function executeSessionTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - let trace: SessionLineageTrace - try { - trace = await ctx.sessionQuery.traceSession(sessionId, exec.signal) - } catch (error: unknown) { - exec.signal.throwIfAborted() - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { - throw new SessionQueryError( - 'session lineage is invalid', - 'SESSION_QUERY_INVALID_LINEAGE', - ) - } - throw error - } - exec.signal.throwIfAborted() + const trace = await sessionQueryCall(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, trace.target.header) const ancestors: SessionRecord[] = [] @@ -471,8 +593,8 @@ async function executeEventTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal) - exec.signal.throwIfAborted() + const trace = await sessionQueryCall(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, trace.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventTrace(sessionId, title, trace) @@ -489,13 +611,13 @@ async function executeEventRead( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await ctx.sessionQuery.readEvent({ - sessionId, - seq: args.seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal) - exec.signal.throwIfAborted() + const window = await sessionQueryCall(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq: args.seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) assertObservedTargetAuthorized(caller, sessionId, window.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventRead(sessionId, title, window) @@ -509,15 +631,6 @@ function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { } const created = timestampRange('created_at', args.created_at_from, args.created_at_to) if (created !== undefined) filters.push({ kind: 'created-at', ...created }) - if (args.parent_session_ids !== undefined || args.include_root_sessions === true) { - const values: Array = [] - if (args.parent_session_ids !== undefined) { - assertNonEmptyArray('parent_session_ids', args.parent_session_ids) - values.push(...args.parent_session_ids.map(SessionId)) - } - if (args.include_root_sessions === true) values.push(null) - filters.push({ kind: 'parent', values }) - } if (args.availability !== undefined) { assertNonEmptyArray('availability', args.availability) filters.push({ kind: 'availability', values: args.availability }) @@ -525,6 +638,12 @@ function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { return filters } +function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { + if (values === undefined) return undefined + assertNonEmptyArray('parent_session_ids', values) + return [...new Set(values.map(SessionId))] +} + interface EventFilterInput { readonly seqFrom?: number | undefined readonly seqTo?: number | undefined @@ -737,19 +856,7 @@ async function collectPages( let cursor: SessionSearchCursor | undefined while (true) { signal.throwIfAborted() - let page: Awaited> - try { - page = await request(cursor) - } catch (error: unknown) { - if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_STALE_CURSOR') { - throw new SessionQueryError( - 'session history changed while paging; retry the complete search call', - 'SESSION_QUERY_STALE_CURSOR', - { cause: error }, - ) - } - throw error - } + const page = await request(cursor) signal.throwIfAborted() for (const item of page.items) { if (!accept(item)) continue @@ -799,13 +906,17 @@ async function authorizeSessionIds( const cwd = caller.header.cwd const other = unique.filter(id => id !== caller.id) if (cwd === undefined || other.length === 0) return authorized - signal.throwIfAborted() - const records = await ctx.sessionQuery.filterSessions([ - { kind: 'id', values: other }, - { kind: 'cwd', values: [cwd] }, - ], signal) - signal.throwIfAborted() - for (const record of records) authorized.add(record.header.id) + const records = await sessionQueryCall(ctx, signal, 'session-id authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: other }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + const requested = new Set(other) + for (const record of records) { + if (requested.has(record.header.id) && recordAuthorized(record, caller)) { + authorized.add(record.header.id) + } + } return authorized } @@ -816,12 +927,11 @@ async function readTitles( signal: AbortSignal, ): Promise { const result = new Map() - signal.throwIfAborted() - const observations = await ctx.sessionQuery.readTitleSnapshots(ids, signal) - signal.throwIfAborted() + const observations = await sessionQueryCall(ctx, signal, 'title observation', () => + ctx.sessionQuery.readTitleSnapshots(ids, signal)) for (const observation of observations) { if (observation.status === 'rejected') { - result.set(observation.sessionId, unavailableTitle(ctx, observation.sessionId, observation.reason)) + result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) continue } assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) @@ -841,17 +951,35 @@ async function readTitle( function unavailableTitle( ctx: Context, - id: SessionIdValue, error: unknown, ): TitleView { - if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error - const code = error instanceof HarnessError ? error.code : 'UNKNOWN' - ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) - return { text: 'untitled', unavailableCode: code } + const sanitized = sanitizeSessionQueryError(ctx, 'title observation item', error) + if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized + return { text: 'untitled', unavailableCode: sanitized.code } } function fullError(error: unknown): string { - return error instanceof Error ? error.stack ?? String(error) : String(error) + try { + return renderFullError(error) + } catch { + return UNPRINTABLE_SERVICE_ERROR + } +} + +function renderFullError(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const diagnostics: string[] = [] + const seen = new Set() + let current: unknown = error + while (current instanceof Error && !seen.has(current)) { + seen.add(current) + diagnostics.push(current.stack ?? String(current)) + current = current.cause + } + /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ + if (current instanceof Error) diagnostics.push('[circular error cause]') + else if (current !== undefined) diagnostics.push(renderFullError(current)) + return diagnostics.join('\nCaused by: ') } function authorizeDescendants( @@ -927,7 +1055,7 @@ function formatSessionSearch( titles: CompleteTitleMap, authorizedParents: ReadonlySet, ): string { - if (collected.items.length === 0) return 'No prior session matches found.' + if (collected.items.length === 0) return formatEmptySessionSearch() const lines = [`Session search results (${collected.items.length}):`] for (const [index, hit] of collected.items.entries()) { const parent = hit.header.parentSession === undefined @@ -955,6 +1083,10 @@ function formatSessionSearch( return lines.join('\n') } +function formatEmptySessionSearch(): string { + return 'No prior session matches found.' +} + function formatEventSearch( sessionId: SessionIdValue, title: TitleView, diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index d52a4d48aa..35ab1fa408 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -363,6 +363,7 @@ describe('input validation and translation', () => { it('normalizes the query and compiles inclusive session/event filters with one parent OR clause', async () => { const mounted = await mount() + createSession(mounted.ctx, 'parent', '/work') await mounted.call('session_search', { query: ' alpha beta ', session_ids: ['a', 'b'], @@ -388,8 +389,8 @@ describe('input validation and translation', () => { from: Date.parse('2026-07-24T00:00:00+08:00'), to: Date.parse('2026-07-24T01:00:00+08:00'), }, - { kind: 'parent', values: ['parent', null] }, { kind: 'availability', values: ['live'] }, + { kind: 'parent', values: ['parent', null] }, { kind: 'cwd', values: ['/work'] }, ], eventFilters: [ @@ -538,6 +539,7 @@ describe('input validation and translation', () => { it('compiles one-sided timestamps and independent root/parent clauses', async () => { const mounted = await mount() + createSession(mounted.ctx, 'parent', '/work') await mounted.call('session_search', { query: 'q', created_at_from: '2024-02-29T00:00Z', @@ -596,6 +598,191 @@ describe('workspace authority and lineage redaction', () => { .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') }) + it('makes hidden and nonexistent parent guesses indistinguishable without calling search', async () => { + const mounted = await mount() + const hiddenParent = createSession(mounted.ctx, 'guessed-hidden-parent-secret', '/outside') + const visibleChild = createSession( + mounted.ctx, + 'visible-child-of-hidden-parent', + '/work', + 20, + hiddenParent.id, + ) + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [sessionHit(visibleChild.id, '/work', 'must not be discoverable', hiddenParent.id)], + }) + + const hidden = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [hiddenParent.id], + }) + const missing = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-missing-parent'], + }) + + expect(hidden).toEqual(missing) + expect(text(hidden)).toBe('No prior session matches found.') + expect(JSON.stringify(hidden)).not.toContain(visibleChild.id) + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('deduplicates parent guesses and sends only authorized parents plus the root marker', async () => { + const mounted = await mount() + const visible = createSession(mounted.ctx, 'visible-parent', '/work') + const hidden = createSession(mounted.ctx, 'hidden-parent-filter-secret', '/outside') + + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [visible.id, hidden.id, visible.id, 'missing-parent'], + include_root_sessions: true, + }) + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [hidden.id], + include_root_sessions: true, + }) + await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['missing-parent'], + include_root_sessions: true, + }) + + const parentValues = FakeQuery.sessionRequests.map(request => + request.sessionFilters?.find(filter => filter.kind === 'parent')) + expect(parentValues).toEqual([ + { kind: 'parent', values: [visible.id, null] }, + { kind: 'parent', values: [null] }, + { kind: 'parent', values: [null] }, + ]) + }) + + it('rejects unrequested or unauthorized records returned during parent preauthorization', async () => { + const mounted = await mount() + const requested = SessionId('requested-parent') + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockResolvedValueOnce([ + { header: header('unrequested-parent', '/work'), live: true, persisted: false }, + { header: header(requested, '/outside'), live: true, persisted: false }, + ]) + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: [requested], + }) + + expect(text(result)).toBe('No prior session matches found.') + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('validates every other search filter before parent preauthorization', async () => { + const mounted = await mount() + const filterSessions = vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + event_seq_from: -1, + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') + expect(filterSessions).not.toHaveBeenCalled() + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('sanitizes parent preauthorization failures without calling search', async () => { + const mounted = await mount() + const secret = 'conflict at hidden-parent-preauthorization-secret' + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce( + new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + expect(FakeQuery.sessionRequests).toEqual([]) + }) + + it('sanitizes direct-target authorization failures before event search', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'authorization-failure-target', '/work') + const secret = 'conflict with hidden-authorization-session-secret' + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions').mockRejectedValueOnce( + new SessionQueryError(secret, 'SESSION_QUERY_SOURCE_CONFLICT'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_event_search', { + session_id: target.id, + query: 'needle', + }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + expect(FakeQuery.eventRequests).toEqual([]) + }) + + it('preserves parent-preauthorization cancellation and waits for cleanup without logging it', async () => { + const mounted = await mount() + const controller = new AbortController() + const cancellation = new SessionQueryError( + 'parent preauthorization cancelled', + 'SESSION_QUERY_ABORTED', + ) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + vi.spyOn(mounted.ctx.sessionQuery, 'filterSessions') + .mockImplementation(async (_filters, signal) => { + if (signal === undefined) throw new Error('expected parent-authorization signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + }) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const pending = mounted.call('session_search', { + query: 'needle', + parent_session_ids: ['guessed-parent'], + }, { signal: controller.signal }) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(cancellation) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(FakeQuery.sessionRequests).toEqual([]) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe('Error: parent preauthorization cancelled') + expect(warn).not.toHaveBeenCalled() + }) + it('redacts an unauthorized ancestor and prunes unauthorized descendant subtrees without hidden ids', async () => { const mounted = await mount() const hiddenParent = createSession(mounted.ctx, 'hidden-parent-secret', '/outside') @@ -635,6 +822,16 @@ describe('workspace authority and lineage redaction', () => { }) it.each([ + { + name: 'sensitive source conflict', + makeError: () => new SessionQueryError( + 'conflict with hidden-lineage-session-secret', + 'SESSION_QUERY_SOURCE_CONFLICT', + ), + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + secret: 'hidden-lineage-session-secret', + }, { name: 'typed query error', makeError: () => new SessionQueryError( @@ -642,23 +839,56 @@ describe('workspace authority and lineage redaction', () => { 'SESSION_QUERY_PERSISTENCE_FAILED', ), code: 'SESSION_QUERY_PERSISTENCE_FAILED', - message: 'unrelated persistence failure', + message: 'session history storage is unavailable', + secret: 'unrelated persistence failure', }, { name: 'plain error', makeError: () => new Error('unrelated plain trace failure'), - code: undefined, - message: 'unrelated plain trace failure', + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + secret: 'unrelated plain trace failure', }, - ])('preserves an unrelated $name from lineage tracing', async ({ makeError, code, message }) => { + ])('sanitizes an unrelated $name from lineage tracing', async ({ makeError, code, message, secret }) => { const mounted = await mount() const target = createSession(mounted.ctx, 'trace-failure-target', '/work') + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockRejectedValueOnce(makeError()) const result = await mounted.call('session_trace', { session_id: target.id }) expect(errorCode(result)).toBe(code) expect(text(result)).toBe(`Error: ${message}`) + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + }) + + it.each([ + 'session_event_trace', + 'session_event_read', + ] as const)('sanitizes typed service diagnostics from %s', async (toolName) => { + const mounted = await mount() + const target = createSession(mounted.ctx, `${toolName}-failure-target`, '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'event' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const secret = `event missing beside hidden-${toolName}-secret` + const failure = new SessionQueryError(secret, 'SESSION_QUERY_EVENT_NOT_FOUND') + if (toolName === 'session_event_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockRejectedValueOnce(failure) + } else { + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockRejectedValueOnce(failure) + } + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call(toolName, { session_id: target.id, seq: 0 }) + + expect(errorCode(result)).toBe('SESSION_QUERY_EVENT_NOT_FOUND') + expect(text(result)).toBe('Error: session event was not found') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) }) it.each([ @@ -681,6 +911,7 @@ describe('workspace authority and lineage redaction', () => { const started = Promise.withResolvers() const abortObserved = Promise.withResolvers() const cleanup = Promise.withResolvers() + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) let observedSignal: AbortSignal | undefined let active = false const holdExactRead = async (signal?: AbortSignal): Promise => { @@ -731,6 +962,7 @@ describe('workspace authority and lineage redaction', () => { expect(active).toBe(false) expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') expect(text(result)).toBe(`Error: ${toolName} cancelled`) + expect(warn).not.toHaveBeenCalled() }) it('preserves caller cancellation while a lineage trace is pending', async () => { @@ -1052,6 +1284,239 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(output).not.toContain('Result cap reached') }) + it.each([ + { + toolName: 'session_search', + args: { query: 'needle' }, + secrets: [ + 'session source conflict at hidden-search-session-secret', + 'hidden-search-cause-secret', + ], + failure: () => new SessionQueryError( + 'session source conflict at hidden-search-session-secret', + 'SESSION_QUERY_SOURCE_CONFLICT', + { cause: new Error('hidden-search-cause-secret') }, + ), + }, + { + toolName: 'session_event_search', + args: { query: 'needle' }, + secrets: [ + 'plain event provider failure at hidden-event-session-secret', + 'hidden-event-cause-secret', + ], + failure: () => new Error( + 'plain event provider failure at hidden-event-session-secret', + { cause: 'hidden-event-cause-secret' }, + ), + }, + ] as const)('sanitizes $toolName provider diagnostics', async ({ toolName, args, secrets, failure }) => { + const mounted = await mount() + if (toolName === 'session_search') { + FakeQuery.sessionSearch = () => Promise.reject(failure()) + } else { + FakeQuery.eventSearch = () => Promise.reject(failure()) + } + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call(toolName, args) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + for (const secret of secrets) { + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + } + }) + + it.each([ + { + name: 'a hostile prototype trap', + secrets: ['proxy payload secret', 'getPrototypeOf secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => new Proxy( + { payload: 'proxy payload secret' }, + { + getPrototypeOf() { + throw new Error('getPrototypeOf secondary secret') + }, + }, + ), + }, + { + name: 'a throwing stack getter', + secrets: ['stack primary secret', 'stack getter secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => { + const error = new Error('stack primary secret') + Object.defineProperty(error, 'stack', { + get() { + throw new Error('stack getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'a throwing cause getter', + secrets: ['cause primary secret', 'cause getter secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => { + const error = new Error('cause primary secret') + Object.defineProperty(error, 'cause', { + get() { + throw new Error('cause getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'throwing string coercion', + secrets: ['string payload secret', 'string coercion secondary secret'], + diagnostic: '[unprintable session query failure]', + failure: (): unknown => ({ + payload: 'string payload secret', + [Symbol.toPrimitive]() { + throw new Error('string coercion secondary secret') + }, + }), + }, + { + name: 'a throwing code getter', + secrets: ['code primary secret', 'code getter secondary secret'], + diagnostic: 'code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { + get() { + throw new Error('code getter secondary secret') + }, + }) + return error + }, + }, + { + name: 'an unknown string code', + secrets: ['unknown code primary secret', '__proto__'], + diagnostic: 'unknown code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'unknown code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { value: '__proto__' }) + return error + }, + }, + { + name: 'a non-string code', + secrets: ['non-string code primary secret', 'non-string code secondary secret'], + diagnostic: 'non-string code primary secret', + failure: (): unknown => { + const error = new SessionQueryError( + 'non-string code primary secret', + 'SESSION_QUERY_PERSISTENCE_FAILED', + ) + Object.defineProperty(error, 'code', { + value: { + toString() { + throw new Error('non-string code secondary secret') + }, + }, + }) + return error + }, + }, + ])('fails generic when inspecting $name is unsafe', async ({ secrets, diagnostic, failure }) => { + const mounted = await mount() + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- hostile unknown rejection is the scenario + FakeQuery.sessionSearch = () => Promise.reject(failure()) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + for (const secret of secrets) expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(diagnostic)) + }) + + it('retains a fixed safe typed failure when only its nested diagnostic is unprintable', async () => { + const mounted = await mount() + const primary = 'typed outer diagnostic secret' + const nested = 'nested prototype secondary secret' + const cause = new Proxy( + {}, + { + getPrototypeOf() { + throw new Error(nested) + }, + }, + ) + FakeQuery.sessionSearch = () => Promise.reject( + new SessionQueryError( + primary, + 'SESSION_QUERY_PERSISTENCE_FAILED', + { cause }, + ), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_PERSISTENCE_FAILED') + expect(text(result)).toBe('Error: session history storage is unavailable') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(nested) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]')) + }) + + it('logs an inspectable cyclic cause chain without exposing it', async () => { + const mounted = await mount() + const outer = new Error('cyclic outer secret') + const inner = new Error('cyclic inner secret') + Object.defineProperty(outer, 'cause', { value: inner }) + Object.defineProperty(inner, 'cause', { value: outer }) + FakeQuery.sessionSearch = () => Promise.reject(outer) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain('cyclic outer secret') + expect(JSON.stringify(result)).not.toContain('cyclic inner secret') + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic outer secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cyclic inner secret')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[circular error cause]')) + }) + + it('fails generic when internal warning logging throws', async () => { + const mounted = await mount() + const primary = 'typed persistence primary secret' + const secondary = 'logger warning secondary secret' + FakeQuery.sessionSearch = () => Promise.reject( + new SessionQueryError(primary, 'SESSION_QUERY_PERSISTENCE_FAILED'), + ) + const warn = vi.spyOn(mounted.ctx.logger, 'warn') + .mockImplementation(() => undefined) + .mockImplementationOnce(() => { + throw new Error(secondary) + }) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(secondary) + expect(warn).toHaveBeenCalledTimes(1) + }) + it('preserves stale-cursor diagnostics without transparently restarting', async () => { const mounted = await mount({ maxSearchResults: 2 }) const cursor = SessionSearchCursor('stale-next') @@ -1070,6 +1535,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => FakeQuery.sessionSearch = () => Promise.resolve({ items: [], nextCursor: cursor }) const result = await mounted.call('session_search', { query: 'needle' }) expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_CURSOR') + expect(text(result)).toBe('Error: session-search provider repeated a continuation cursor') expect(FakeQuery.sessionRequests).toHaveLength(2) }) @@ -1166,7 +1632,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) const result = await mounted.call('session_search', { query: 'needle' }) expect(result.isError).toBe(false) - expect(text(result)).toContain('untitled (title unavailable: TITLE_BACKEND)') + expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)') + expect(JSON.stringify(result)).not.toContain('title backend failed') expect(warn).toHaveBeenCalledWith(expect.stringContaining('title backend failed')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('HarnessError')) }) @@ -1174,7 +1641,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('reports unknown title failures and preserves an Error without a stack', async () => { const mounted = await mount() const first = createSession(mounted.ctx, 'unknown-title', '/work') - const second = createSession(mounted.ctx, 'stackless-title', '/work') + const second = createSession(mounted.ctx, 'second-title-failure', '/work') const stackless = new Error('stackless') Object.defineProperty(stackless, 'stack', { value: undefined }) const readTitles = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots') @@ -1190,13 +1657,62 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => }) const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) const result = await mounted.call('session_search', { query: 'needle' }) - expect(text(result)).toContain('title unavailable: UNKNOWN') + expect(text(result)).toContain('title unavailable: SESSION_QUERY_TOOL_FAILED') + expect(JSON.stringify(result)).not.toContain('string failure') + expect(JSON.stringify(result)).not.toContain('stackless') expect(readTitles).toHaveBeenCalledTimes(1) expect(readTitles.mock.calls[0]?.[0]).toEqual([first.id, second.id]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('string failure')) expect(warn).toHaveBeenCalledWith(expect.stringContaining('Error: stackless')) }) + it('isolates an unprintable per-title failure behind the generic unavailable marker', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'hostile-title-failure', '/work') + const primary = 'per-title proxy payload secret' + const secondary = 'per-title prototype secondary secret' + const reason = new Proxy( + { payload: primary }, + { + getPrototypeOf() { + throw new Error(secondary) + }, + }, + ) + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots').mockResolvedValueOnce([{ + sessionId: hit.id, + status: 'rejected', + reason, + }]) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(result.isError).toBe(false) + expect(text(result)).toContain('untitled (title unavailable: SESSION_QUERY_TOOL_FAILED)') + expect(JSON.stringify(result)).not.toContain(primary) + expect(JSON.stringify(result)).not.toContain(secondary) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('[unprintable session query failure]')) + }) + + it('sanitizes a thrown batch-title service failure instead of rendering its diagnostic', async () => { + const mounted = await mount() + const hit = createSession(mounted.ctx, 'thrown-title-failure', '/work') + const secret = 'title batch failed beside hidden-title-session-secret' + FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshots') + .mockRejectedValueOnce(new Error(secret)) + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) + + const result = await mounted.call('session_search', { query: 'needle' }) + + expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_FAILED') + expect(text(result)).toBe('Error: session query operation failed') + expect(JSON.stringify(result)).not.toContain(secret) + expect(warn).toHaveBeenCalledWith(expect.stringContaining(secret)) + }) + it('does not downgrade cancellation during title enrichment', async () => { const mounted = await mount() const hit = createSession(mounted.ctx, 'abort-title', '/work') @@ -1237,6 +1753,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const result = await mounted.call('session_search', { query: 'needle' }) expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(result)).toBe('Error: session target is outside the caller workspace') + expect(JSON.stringify(result)).not.toContain('title observation became unauthorized') expect(text(result)).not.toContain('title unavailable') }) @@ -1360,6 +1878,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('passes the exact execution signal to every FTS page and stops on cancellation', async () => { const mounted = await mount() const controller = new AbortController() + const warn = vi.spyOn(mounted.ctx.logger, 'warn').mockImplementation(() => undefined) let started!: () => void const bodyStarted = new Promise((resolve) => { started = resolve }) FakeQuery.sessionSearch = (_request, exec) => new Promise((_resolve, reject) => { @@ -1368,13 +1887,15 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => reject(new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED')) }, { once: true }) }) + const cancellation = new SessionQueryError('aborted', 'SESSION_QUERY_ABORTED') const pending = mounted.call('session_search', { query: 'needle' }, { signal: controller.signal }) await bodyStarted - controller.abort() + controller.abort(cancellation) const result = await pending expect(result.isError).toBe(true) expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') expect(FakeQuery.searchSignals).toEqual([controller.signal]) + expect(warn).not.toHaveBeenCalled() }) }) From 224e00b2bdf53d3d0083f43681452f532317d85a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 21:27:07 +0800 Subject: [PATCH 17/19] fix: quiesce cancelled session reconciliation --- ...23-unified-session-query-service.i18n.yaml | 4 +- ...026-07-23-unified-session-query-service.md | 4 + ...-07-23-unified-session-query-service.zh.md | 4 + ...026-07-10-sqlite-session-query-provider.md | 6 +- docs/cordis-catalog/services.md | 3 +- docs/core-data-structures/persistence.md | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/src/index.ts | 8 +- .../tests/jsonl.spec.ts | 50 ++++ .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 5 +- .../tests/sqlite.spec.ts | 25 ++ .../session-persistence/README.md | 4 +- .../session-persistence/src/index.ts | 3 +- .../session-persistence/tests/contract.ts | 2 + .../tests/persistence.spec.ts | 3 +- .../session-query-sqlite/README.md | 2 +- .../session-query-sqlite/src/index.ts | 15 +- .../session-query-sqlite/tests/sqlite.spec.ts | 242 +++++++++++++++++- 20 files changed, 359 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml index 2a27e6432f..7b83a5dc52 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-23-unified-session-query-service.md: 0a466e1c36ff1796c858666b0eb36bbd0f480bb0 -2026-07-23-unified-session-query-service.zh.md: 448122b8e6951058b9f633cd56112b0391e1912e +2026-07-23-unified-session-query-service.md: 676a42017ca42f9e649f6529f84787e7162faac0 +2026-07-23-unified-session-query-service.zh.md: d4449a415840d61cbb10f88def1062a13e556749 diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md index 0a466e1c36..676a42017c 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.md @@ -16,6 +16,8 @@ The interface package already owns the shared record, filter, trace, search-requ `SessionQuerySqlite` extends that service and is the sole concrete backend. One mounted instance therefore exposes every operation through `ctx.sessionQuery`; its inherited exact operations use the shared corpus implementation, while its SQLite-owned lifecycle observes sources, reconciles the derived FTS index, ranks matches, and owns cursor generations. The interface package has no standalone concrete plugin, search-provider registry, or second context key. +SQLite reconciliation is one quiescent serialized state machine. It passes the caller's exact abort signal into durable snapshot listing and inspection, awaits each started backend operation itself, and checks cancellation after every await and before starting the next source or index operation. Cancellation therefore cannot release the serializer while an ignored or cooperative backend call is still cleaning up, and it cannot start a subsequent listing, inspection, reconciliation, or query after the signal is observed. + Backend configuration includes the inherited `readWindowMax` setting alongside its own index path, journal mode, page limits, and snippet limit. First-party apps that need session queries mount the SQLite backend and place its disposable index beside their configured persistence root. This service topology supersedes the separate-key portion of the [exact query decision](../feature/2026-07-10-session-query-service.md) and [SQLite search decision](../feature/2026-07-10-sqlite-session-query-provider.md); their corpus, query, tokenizer, reconciliation, and safety decisions remain in force. @@ -32,4 +34,6 @@ Consumers inject one service and can combine exact and full-text operations with The unified object deliberately retains two internal observation strategies: exact operations read authoritative live/persisted sources per call, while full-text operations reconcile a disposable index. Sharing the context key does not make the derived index authoritative or couple exact-read availability to an FTS query. +Queued cancellation remains prompt. Cancellation during active asynchronous source observation waits for that started operation to settle, which makes rejection a quiescence boundary and preserves single-file execution for a following search. Synchronous SQLite statements remain non-preemptible and are bracketed by signal checks. + Unit coverage pins inherited and abstract behavior on one key, SQLite coverage exercises both operation families on the concrete backend, and the real Loader path verifies that one exported plugin registers the combined service. diff --git a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md index 448122b8e6..d4449a4158 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-unified-session-query-service.zh.md @@ -16,6 +16,8 @@ Status: implemented `SessionQuerySqlite` 扩展该服务,并且是唯一的具体后端。因此,一个挂载实例便可通过 `ctx.sessionQuery` 暴露全部操作;其继承的精确操作使用共享的语料库实现,而由 SQLite 管理的生命周期负责观察数据源、对齐派生 FTS 索引、对匹配项排序并管理游标代际。接口包不提供独立的具体插件、搜索提供方注册表或第二个上下文键。 +SQLite 的对齐过程是一个具备静止性保证的串行状态机。它将调用方的原始中止信号传给持久化快照列表与检查操作,直接等待每个已经启动的后端操作,并在每次等待后以及启动下一个数据源或索引操作前检查是否已取消。因此,即使后端忽略取消或正在配合清理,串行器也不会提前释放;观察到中止信号后,也不会再启动后续的列表、检查、对齐或查询操作。 + 后端配置除了自身的索引路径、日志模式、分页限制与文本片段长度上限外,还包含继承的 `readWindowMax` 设置。需要会话查询的第一方应用挂载 SQLite 后端,并将其可丢弃索引放在已配置的持久化根目录旁。 这一服务拓扑取代了[精确查询决策](../feature/2026-07-10-session-query-service.md)和 [SQLite 搜索决策](../feature/2026-07-10-sqlite-session-query-provider.md)中关于分离上下文键的部分;其中关于语料库、查询、分词器、对齐与安全性的决策仍然有效。 @@ -32,4 +34,6 @@ Status: implemented 统一后的对象有意保留两种内部观察策略:精确操作在每次调用时读取权威的实时源或持久化源,全文操作则使可丢弃索引与数据源对齐。共用上下文键不会让派生索引成为权威来源,也不会使精确读取的可用性依赖 FTS 查询。 +排队阶段的取消仍会及时生效。在异步数据源观察已经开始后取消时,调用方会等待该操作完成清理后才收到拒绝;因此拒绝本身构成静止边界,并保证后续搜索仍按单一串行流程执行。同步 SQLite 语句无法在执行中被抢占,服务会在其前后检查中止信号。 + 单元测试在同一个键上同时固定继承实现与抽象方法的契约,SQLite 测试在具体后端上覆盖两类操作,真实 Loader 路径则验证单个导出的插件能够注册组合后的服务。 diff --git a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md index ff57904358..7306ee6bd9 100644 --- a/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md +++ b/.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md @@ -32,13 +32,13 @@ Both persistent and live FTS5 tables use `unicode61`. The implementation experim The shared extractor includes message text, reasoning, nested tool-call/result content, tool names and arguments, blocked-prompt reasons, todo status/content, and error or terminal status detail. Structural boundaries, stream chunks, request headers, successful completion markers, and unknown declaration-merged event/content variants produce no document. Surface classification reuses `foldSurface()` so search agrees with model-history derivation. -One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. +One serialized operation reads the provider-neutral `SessionPersistence` snapshot listing, compares each source-qualified opaque revision with the revision stored beside the indexed session, loads only new or changed logs, reconciles rows in one transaction, and executes the query. It passes the caller's exact abort signal into snapshot listing and non-mutating inspection, directly awaits every started backend operation, and checks cancellation after each await and before starting more work. Cancellation therefore rejects only after active backend work is quiescent, starts no subsequent observation or reconciliation step, and keeps a following search serialized behind cleanup even if a backend ignores the signal. The operation never calls the backend's mutating `load()` for an id currently owned by `ctx.sessions`; the TEMP overlay records persisted availability, and the durable base refreshes after the live owner detaches. A revision identifies its backing persistence store as well as the backend-local log revision, so reopening against the same store reuses indexed rows while switching to an independent store cannot collide on a session id and local counter. Observation repeats when listing changes during a load; this incorporates a mutating load repair's refreshed revision before commit. Repeated queries and unchanged reopen load no full persisted logs. New, changed, and deleted sessions update on the next stable search. A source or extraction failure cannot mark a row current, and a transaction failure rolls back so a later search retries. Persisted documents survive restarts. Live sessions use connection-local TEMP tables, shadow the persisted base for the same id, and reveal that base on detach. Closing the database drops live rows. Unmounting persistence hides durable rows without treating absence as authoritative deletion; remounting observes and reconciles the backend again. Conflicting immutable live and durable headers fail rather than combining sources. The derived schema has its own application id and monotonic schema version. A recognized incompatible version resets only this derived database. A database with a foreign application id or unrecognized user tables is refused before journal-mode mutation, which prevents an accidentally configured canonical session database from being changed. On POSIX filesystems, missing directories and database files are created owner-only so new SQLite sidecars inherit that mode; existing modes are preserved. One service in one process exclusively owns a derived-index path; cross-process writers are unsupported because generations and live TEMP shadow state are connection-owned. -Cancellation rejects queued operations and caller waits around asynchronous source observation without committing an aborted observation. Node's synchronous `DatabaseSync` MATCH call cannot be interrupted once it is executing on the JavaScript thread, so the service checks the signal at serialized boundaries but does not promise mid-statement preemption. +Cancellation rejects queued operations promptly. Once asynchronous source observation starts, the caller waits for that backend promise to settle before rejection, without committing an aborted observation or starting more source/index work. Node's synchronous `DatabaseSync` metadata and MATCH calls cannot be interrupted once executing on the JavaScript thread, so the service checks the signal around those calls but does not promise mid-statement preemption. ## Alternatives considered @@ -52,6 +52,6 @@ Cancellation rejects queued operations and caller waits around asynchronous sour Search has a small provider-neutral API while its only backend owns every derived-index state transition. The separate database adds configuration and a lightweight snapshot read before queries, but index corruption, reset, and tokenizer changes cannot endanger canonical logs. Durable revisions avoid full-log reads and rewrites for unchanged sessions; TEMP live overlays preserve current-session truth without making uncheckpointed events durable. -The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is effective while queued or awaiting sources, but synchronous SQLite execution remains a non-preemptible section. +The chosen tokenizer supports short tokens with a smaller index but does not promise substring recall. Literal phrases make query syntax safe and predictable at the cost of excluding boolean/full MATCH expressions. Cancellation is prompt while queued and quiescent while awaiting sources; synchronous SQLite execution remains a non-preemptible section bracketed by signal checks. Unit coverage pins extraction, filters, both search scopes, all default surfaces, metadata-before-ranking, snippets, literal escaping, deterministic ties, complete pagination, scoped cursor invalidation, dynamic persistence mount/unmount, restart reconciliation, live shadow/reveal/reopen, schema safety, rollback retry, and queued/in-flight source-wait cancellation. A keyless real-Loader-path test combines the package with the real SQLite persistence backend. diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 9281d6b2fc..3657d5e05c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -960,9 +960,10 @@ abstract list(signal?: AbortSignal): Promise * successful mutating {@link load} repair changes the next listed revision. * Revisions also distinguish independently backed stores so backend-local * counters cannot compare equal across different persistence sources. + * @param signal - optional cancellation for backend snapshot-listing work. * @returns one header and opaque revision per materialized session without loading full logs. */ -abstract listSnapshots(): Promise +abstract listSnapshots(signal?: AbortSignal): Promise ``` Types: [SessionEvent](../core-data-structures/core.md) · [SessionHeader](../core-data-structures/persistence.md) · [SessionId](../core-data-structures/core.md) · [SessionLocation](../core-data-structures/persistence.md) · [SessionPersistenceSnapshot](../core-data-structures/persistence.md) diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index f45eb0417a..af34dfa058 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -126,7 +126,7 @@ interface SessionPersistenceSnapshot { ## The backends -Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: +Both implement the same abstract `SessionPersistence` (locate/create/append/load/inspect/list/listSnapshots over `SessionEvent`, with optional cancellation on observation methods) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic: - **[dsh-session-persistence-jsonl](../../packages/session-persistence/session-persistence-jsonl)** — an append-only logical JSONL log per session, stored as checksummed concatenated Zstandard frames by default or raw lines by configuration, with crash-safe atomic writes, interrupted-turn recovery, and a read/replay path. - **[dsh-session-persistence-sqlite](../../packages/session-persistence/session-persistence-sqlite)** — `node:sqlite`, one row per `SessionEvent`. The row shape `(session_id, seq, type, time, data, source_event_seqs, surface_op)` maps 1:1 onto the event, including optional surface metadata, so there is no parallel persisted schema to keep in sync. diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index aefea025b1..3c57dbd478 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -477,8 +477,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */', }, { - signature: 'abstract listSnapshots(): Promise', - jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', + signature: 'abstract listSnapshots(signal?: AbortSignal): Promise', + jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @param signal - optional cancellation for backend snapshot-listing work.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */', }, ], }, diff --git a/packages/session-persistence/session-persistence-jsonl/README.md b/packages/session-persistence/session-persistence-jsonl/README.md index bf86bf8633..b7b70df847 100644 --- a/packages/session-persistence/session-persistence-jsonl/README.md +++ b/packages/session-persistence/session-persistence-jsonl/README.md @@ -39,7 +39,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects. - **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged. - **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type. -- **Lightweight revisions.** `listSnapshots()` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. +- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another. ## Write path diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 6b2fe3d0cf..9740fbb7d8 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -281,11 +281,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } /** List metadata plus a stat-derived identity for each append-only log. */ - async listSnapshots(): Promise { + async listSnapshots(signal?: AbortSignal): Promise { const snapshots: SessionPersistenceSnapshot[] = [] - for (const artifact of await this.listArtifacts()) { + for (const artifact of await this.listArtifacts(signal)) { + signal?.throwIfAborted() try { const identity = await stat(artifact.path, { bigint: true }) + signal?.throwIfAborted() snapshots.push({ header: artifact.header, revision: SessionPersistenceRevision([ @@ -297,9 +299,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi ].join(':')), }) } catch (error: unknown) { + signal?.throwIfAborted() if (!isENOENT(error)) throw error } } + signal?.throwIfAborted() return snapshots } diff --git a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts index 2b49b7d55b..bc5142f1cb 100644 --- a/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts @@ -265,6 +265,56 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { discovery.mockRestore() }) + it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => { + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(signal?: AbortSignal): Promise> + } + const started = Promise.withResolvers() + const cleanup = Promise.withResolvers() + vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => { + if (signal === undefined) throw new Error('expected snapshot-list signal') + started.resolve(signal) + await cleanup.promise + return [] + }) + const reason = new Error('JSONL snapshot discovery cancelled') + const controller = new AbortController() + const pending = ctx.sessionPersistence.listSnapshots(controller.signal) + expect(await started.promise).toBe(controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + + controller.abort(reason) + await Promise.resolve() + expect(settled).toBe(false) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + }) + + it('checks cancellation after an uncancellable snapshot stat settles', async () => { + const m = meta('snapshot-stat-cancellation') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const persistence = ctx.sessionPersistence as unknown as { + listArtifacts(signal?: AbortSignal): Promise> + } + const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{ + header: m, + path: rawLogPath(root, m.cwd, m.id), + }]) + const reason = new Error('JSONL snapshot stat cancelled') + const controller = new AbortController() + const pending = ctx.sessionPersistence.listSnapshots(controller.signal) + queueMicrotask(() => { controller.abort(reason) }) + + await expect(pending).rejects.toBe(reason) + expect(discovery).toHaveBeenCalledWith(controller.signal) + }) + it('rejects a stored v0 log containing a legacy request/header-delta event', async () => { const m = meta('legacy-header-delta', '/legacy') const path = rawLogPath(root, m.cwd, m.id) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index f1f4bc1f7b..dda164fc33 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -20,7 +20,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di - **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row). - **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor. - **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged. -- **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. +- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible. ## Configuration (schemastery) diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 0c1159f139..f771b9e3a7 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -266,9 +266,12 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } /** List metadata with a source-qualified monotonic revision per session. */ - async listSnapshots(): Promise { + async listSnapshots(signal?: AbortSignal): Promise { + signal?.throwIfAborted() await this.ready + signal?.throwIfAborted() const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[] + signal?.throwIfAborted() return rows.map(row => ({ header: rowToMeta(row), revision: SessionPersistenceRevision( diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3976e71549..e70c041bca 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -441,6 +441,31 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await second.dispose() }) + it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => { + const b = await backend() + const internals = b.ctx.sessionPersistence as unknown as { ready: Promise } + const originalReady = internals.ready + const readiness = Promise.withResolvers() + internals.ready = readiness.promise + const reason = new Error('SQLite snapshot readiness cancelled') + const controller = new AbortController() + const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + + controller.abort(reason) + await Promise.resolve() + expect(settled).toBe(false) + + readiness.resolve(undefined) + await expect(pending).rejects.toBe(reason) + internals.ready = originalReady + await b.dispose() + }) + it('exposes the schema version constant', () => { expect(SCHEMA_VERSION).toBe(8) }) diff --git a/packages/session-persistence/session-persistence/README.md b/packages/session-persistence/session-persistence/README.md index fa734fb736..199cad10c5 100644 --- a/packages/session-persistence/session-persistence/README.md +++ b/packages/session-persistence/session-persistence/README.md @@ -14,7 +14,7 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | -| `listSnapshots(): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. | +| `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | ## Invariants every backend must honor @@ -33,7 +33,7 @@ Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle. -The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. +The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it. The `PersistenceBackend` hooks (the only seam between the coordinator and storage): diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index 9eee07a323..9279e3c42c 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -122,9 +122,10 @@ export abstract class SessionPersistence extends Service { * successful mutating {@link load} repair changes the next listed revision. * Revisions also distinguish independently backed stores so backend-local * counters cannot compare equal across different persistence sources. + * @param signal - optional cancellation for backend snapshot-listing work. * @returns one header and opaque revision per materialized session without loading full logs. */ - abstract listSnapshots(): Promise + abstract listSnapshots(signal?: AbortSignal): Promise } export default SessionPersistence diff --git a/packages/session-persistence/session-persistence/tests/contract.ts b/packages/session-persistence/session-persistence/tests/contract.ts index eb77235057..e61fe65bb7 100644 --- a/packages/session-persistence/session-persistence/tests/contract.ts +++ b/packages/session-persistence/session-persistence/tests/contract.ts @@ -227,9 +227,11 @@ export function runPersistenceContract(name: string, make: () => Promise structuredClone(e.meta)) } - async listSnapshots(): Promise { + async listSnapshots(signal?: AbortSignal): Promise { + signal?.throwIfAborted() return [...this.store.values()].map(entry => ({ header: structuredClone(entry.meta), revision: SessionPersistenceRevision(`events:${entry.events.length}`), diff --git a/packages/session-query/session-query-sqlite/README.md b/packages/session-query/session-query-sqlite/README.md index 693b1275f2..3d2f2ce16b 100644 --- a/packages/session-query/session-query-sqlite/README.md +++ b/packages/session-query/session-query-sqlite/README.md @@ -34,7 +34,7 @@ The database is disposable but reset is guarded: every recognized schema version The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. NUL is rejected in queries; reserved highlight markers and NUL in documents are normalized before indexing so presentation markers cannot collide with source text. -Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary. +Abort signals stop queued work and flow unchanged through snapshot listing and non-mutating inspection. Once source work starts, the serialized state machine awaits that backend promise itself—even when a backend ignores cancellation—then checks the signal before starting any further listing, inspection, reconciliation, or query work. The caller therefore observes cancellation only after started backend work is quiescent, and a later search cannot enter the serializer while that cleanup is pending. Node's synchronous `DatabaseSync` API cannot interrupt a metadata or MATCH statement already executing on the JavaScript thread; signals are checked immediately before and after those non-preemptible calls. ## Model Experience diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 3acf806646..0c073ccea5 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -352,6 +352,7 @@ export class SessionQuerySqlite extends SessionQueryService { } private async _reconcile(signal: AbortSignal | undefined): Promise { + assertNotAborted(signal) const db = this._requireDb() const persistedRows = db.prepare( 'SELECT id, revision, generation FROM persisted_sessions', @@ -452,7 +453,8 @@ export class SessionQuerySqlite extends SessionQueryService { try { const canReuseIndexed = this._lastPersistenceIdentity === undefined || this._lastPersistenceIdentity === persistenceBinding.identity - const before = await waitWithAbort(persistence.listSnapshots(), signal) + const before = await persistence.listSnapshots(signal) + assertNotAborted(signal) persisted = materializePersistenceSnapshots(before) for (const entry of persisted.values()) { if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue @@ -461,13 +463,16 @@ export class SessionQuerySqlite extends SessionQueryService { // crash-repair side effects; the live-membership retry below makes // the returned observation live-preferred. if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue - const loaded = await waitWithAbort(persistence.inspect(entry.header.id), signal) + assertNotAborted(signal) + const loaded = await persistence.inspect(entry.header.id, signal) + assertNotAborted(signal) assertSessionHeadersCompatible(entry.header, loaded.meta) entry.loaded = observeSession(loaded.meta, loaded.events) } - const after = materializePersistenceSnapshots( - await waitWithAbort(persistence.listSnapshots(), signal), - ) + assertNotAborted(signal) + const afterSnapshots = await persistence.listSnapshots(signal) + assertNotAborted(signal) + const after = materializePersistenceSnapshots(afterSnapshots) if (!samePersistenceSnapshots(persisted, after)) continue if (this._persistenceBinding !== persistenceBinding) continue } catch (error: unknown) { diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index b777ad8455..d39c82f336 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -69,11 +69,16 @@ class TestPersistence extends SessionPersistence { static nextRevision = 0 static loads = new Map() static inspections = new Map() + static inspectSignals: Array = [] + static snapshotSignals: Array = [] static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined - static inspectEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void | Promise) | undefined + static inspectEffect: (( + entry: { meta: SessionHeader; events: SessionEvent[] }, + signal?: AbortSignal, + ) => void | Promise) | undefined static listGate: Promise | undefined static listStarted: (() => void) | undefined - static snapshotEffect: (() => void | Promise) | undefined + static snapshotEffect: ((signal?: AbortSignal) => void | Promise) | undefined static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined static failure: unknown @@ -86,6 +91,8 @@ class TestPersistence extends SessionPersistence { this.revisions = new Map() this.loads = new Map() this.inspections = new Map() + this.inspectSignals = [] + this.snapshotSignals = [] this.loadEffect = undefined this.inspectEffect = undefined for (const entry of entries) this.set(entry) @@ -128,12 +135,13 @@ class TestPersistence extends SessionPersistence { return structuredClone(entry) } - async inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + async inspect(id: SessionIdType, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { TestPersistence.inspections.set(id, (TestPersistence.inspections.get(id) ?? 0) + 1) + TestPersistence.inspectSignals.push(signal) if (TestPersistence.failure !== undefined) throw TestPersistence.failure const entry = TestPersistence.entries.get(id) if (entry === undefined) throw new Error('missing test session') - await TestPersistence.inspectEffect?.(entry) + await TestPersistence.inspectEffect?.(entry, signal) TestPersistence.inspectEffect = undefined return structuredClone(entry) } @@ -146,7 +154,8 @@ class TestPersistence extends SessionPersistence { } - async listSnapshots(): Promise { + async listSnapshots(signal?: AbortSignal): Promise { + TestPersistence.snapshotSignals.push(signal) TestPersistence.listStarted?.() await TestPersistence.listGate if (TestPersistence.failure !== undefined) throw TestPersistence.failure @@ -155,7 +164,7 @@ class TestPersistence extends SessionPersistence { header: structuredClone(entry.meta), revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`), })) - await TestPersistence.snapshotEffect?.() + await TestPersistence.snapshotEffect?.(signal) return snapshots } } @@ -1209,6 +1218,167 @@ describe('SQLite schema, cancellation, and real persistence integration', () => } }) + it.each(['sessions', 'events'] as const)( + 'forwards one exact reconciliation signal through both snapshot lists and persisted inspection for %s search', + async (scope) => { + const durable = header(`signal-${scope}`) + TestPersistence.reset([{ meta: durable, events: messageEvents('signal needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + + const result = scope === 'sessions' + ? await ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + : await ctx.sessionQuery.searchEvents( + { sessionId: durable.id, query: 'needle' }, + { signal: controller.signal }, + ) + + expect(result.items).toHaveLength(1) + expect(TestPersistence.snapshotSignals).toEqual([controller.signal, controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + }, + ) + + it.each(['sessions', 'events'] as const)( + 'starts no persistence observation for a pre-aborted %s search', + async (scope) => { + const durable = header(`pre-aborted-${scope}`) + TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + controller.abort(new Error(`pre-aborted ${scope}`)) + + const pending = scope === 'sessions' + ? ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + : ctx.sessionQuery.searchEvents( + { sessionId: durable.id, query: 'needle' }, + { signal: controller.signal }, + ) + + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + expect(TestPersistence.snapshotSignals).toEqual([]) + expect(TestPersistence.inspectSignals).toEqual([]) + }, + ) + + it('awaits cooperative snapshot-list cancellation cleanup without starting another observation step', async () => { + const durable = header('cooperative-list-abort') + TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + TestPersistence.snapshotEffect = async (signal) => { + TestPersistence.snapshotEffect = undefined + if (signal === undefined) throw new Error('expected reconciliation signal') + started.resolve(signal) + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + abortObserved.resolve(undefined) + await cleanup.promise + signal.throwIfAborted() + } + const controller = new AbortController() + const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + expect(await started.promise).toBe(controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + + controller.abort(new Error('cooperative list cancellation')) + await abortObserved.promise + expect(settled).toBe(false) + expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + }) + + it('keeps a second search serialized while an abort-ignoring snapshot list finishes', async () => { + const durable = header('serialized-list-abort') + TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const cleanup = Promise.withResolvers() + const started = Promise.withResolvers() + TestPersistence.listGate = cleanup.promise + TestPersistence.listStarted = () => { + TestPersistence.listStarted = undefined + started.resolve(undefined) + } + const controller = new AbortController() + const first = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + await started.promise + let firstSettled = false + let secondSettled = false + void first.then( + () => { firstSettled = true }, + () => { firstSettled = true }, + ) + controller.abort(new Error('ignored list cancellation')) + const second = ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' }) + void second.then( + () => { secondSettled = true }, + () => { secondSettled = true }, + ) + await Promise.resolve() + + expect(firstSettled).toBe(false) + expect(secondSettled).toBe(false) + expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([]) + + cleanup.resolve(undefined) + await expect(first).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + await expect(second).resolves.toMatchObject({ items: [{ sessionId: durable.id }] }) + }) + + it('awaits an abort-ignoring inspection and starts neither another inspection nor the after-list', async () => { + const first = header('ignored-inspect-first') + const second = header('ignored-inspect-second') + TestPersistence.reset([ + { meta: first, events: messageEvents('first needle') }, + { meta: second, events: messageEvents('second needle') }, + ]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const started = Promise.withResolvers() + const cleanup = Promise.withResolvers() + TestPersistence.inspectEffect = async (_entry, signal) => { + TestPersistence.inspectEffect = undefined + if (signal === undefined) throw new Error('expected reconciliation signal') + started.resolve(signal) + await cleanup.promise + } + const controller = new AbortController() + const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + expect(await started.promise).toBe(controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + + controller.abort(new Error('ignored inspect cancellation')) + await Promise.resolve() + expect(settled).toBe(false) + expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) + expect(TestPersistence.inspections.get(first.id)).toBe(1) + expect(TestPersistence.inspections.get(second.id)).toBeUndefined() + + cleanup.resolve(undefined) + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + expect(TestPersistence.snapshotSignals).toEqual([controller.signal]) + expect(TestPersistence.inspections.get(second.id)).toBeUndefined() + }) + it('cancels both queued and in-flight source waits without committing them', async () => { TestPersistence.reset() const ctx = await liveContext() @@ -1262,8 +1432,15 @@ describe('SQLite schema, cancellation, and real persistence integration', () => const active = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: activeController.signal }) await activeStarted activeController.abort() - await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + let activeSettled = false + void active.then( + () => { activeSettled = true }, + () => { activeSettled = true }, + ) + await Promise.resolve() + expect(activeSettled).toBe(false) releaseActive() + await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 }) @@ -1271,6 +1448,57 @@ describe('SQLite schema, cancellation, and real persistence integration', () => .resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] }) }) + it.each([ + [new Error('ready error'), 'ready error'], + ['non-error ready failure', 'session-search dependency rejected with a non-Error value'], + ])('normalizes a rejected readiness wait before mapping it to an index error', async (failure, detail) => { + TestPersistence.reset() + const ctx = await liveContext() + const internals = ctx.sessionQuery as unknown as { + _ready: Promise + _ensureReady(signal: AbortSignal): Promise + } + internals._ready = Promise.resolve().then(() => { + throw failure + }) + + await expect(internals._ensureReady(new AbortController().signal)) + .rejects.toThrow(`session-search SQLite index failed to open: ${detail}`) + }) + + it('checks cancellation after readiness before reconciliation accesses SQLite', async () => { + TestPersistence.reset() + const ctx = await liveContext() + const internals = ctx.sessionQuery as unknown as { + _db: DatabaseSync + _ready: Promise + _ensureReady(signal: AbortSignal | undefined): Promise + } + const readiness = Promise.withResolvers() + internals._ready = readiness.promise + const readyWaitStarted = Promise.withResolvers() + const ensureReady = internals._ensureReady.bind(internals) + vi.spyOn(internals, '_ensureReady').mockImplementation(async (signal) => { + const pending = ensureReady(signal) + readyWaitStarted.resolve(undefined) + return pending + }) + const prepare = vi.spyOn(internals._db, 'prepare') + const reason = new Error('cancelled after readiness') + const controller = new AbortController() + const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal }) + await readyWaitStarted.promise + + const queueBoundaryAbort = readiness.promise.then(() => { + queueMicrotask(() => { controller.abort(reason) }) + }) + readiness.resolve(undefined) + await queueBoundaryAbort + + await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) + expect(prepare).not.toHaveBeenCalled() + }) + it('rejects queued and future work when close waits for an accepted operation', async () => { TestPersistence.reset() let release!: () => void From 3da324d1e2aee5b8b04619cec44004eaf7acb4c4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 25 Jul 2026 15:38:09 +0800 Subject: [PATCH 18/19] refactor(session-query): split model-facing tool modules --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 2 + ...-24-model-facing-session-query-tools.zh.md | 2 + docs/config-catalog.md | 2 +- .../tool-session-query/src/index.ts | 1147 +---------------- .../tool-session-query/src/input.ts | 307 +++++ .../tool-session-query/src/operations.ts | 281 ++++ .../tool-session-query/src/presentation.ts | 255 ++++ .../src/service-boundary.ts | 171 +++ .../src/workspace-access.ts | 255 ++++ 10 files changed, 1295 insertions(+), 1131 deletions(-) create mode 100644 packages/session-query/tool-session-query/src/input.ts create mode 100644 packages/session-query/tool-session-query/src/operations.ts create mode 100644 packages/session-query/tool-session-query/src/presentation.ts create mode 100644 packages/session-query/tool-session-query/src/service-boundary.ts create mode 100644 packages/session-query/tool-session-query/src/workspace-access.ts diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 88363d0aca..86b4e1deed 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: aea490f3569dd95bffb6ebbaae5a130e6440c281 -2026-07-24-model-facing-session-query-tools.zh.md: eae100375fe7abf91ba3e503808a6d98b540255e +2026-07-24-model-facing-session-query-tools.md: bc9143150d1e17eda9eab7f4864ed3a2f4983157 +2026-07-24-model-facing-session-query-tools.zh.md: c8a0c70789f21e4bbca523b6acc81925fb17b604 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index aea490f356..bc9143150d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -12,6 +12,8 @@ The unified `ctx.sessionQuery` service exposes exact reads, filters, relationshi `@deepseek-ai/dsh-tool-session-query` is the model-facing consumer of `ctx.sessionQuery`. It registers five narrow read-only tools: `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. The package imports the interface rather than the SQLite implementation, owns model argument validation and readable text rendering, and contributes one concise prompt section that teaches the prior-history search and search-to-trace/read workflow. +The package entrypoint is only the public composition root for configuration, prompt registration, and tool registration. Its internal modules follow the execution boundary: `input.ts` owns model schemas, normalization, and filter construction; `service-boundary.ts` contains provider calls and model-safe error translation; `workspace-access.ts` owns caller identity, workspace authorization, title access, and lineage projection; `operations.ts` orchestrates the five service workflows; and `presentation.ts` renders tool results and call cards. This keeps policy in its owning layer without changing the package contract. + `session_search` groups full-text matches by session and exposes typed session and event metadata filters. `session_event_search` searches one session, defaulting to the caller's current session. `session_trace` returns the complete authorized ancestor chain and recursive descendant trees. `session_event_trace` returns every known positional replacement and direct provenance relationship for one event. `session_event_read` returns the exact target event as unabridged JSON and optionally summarizes a bounded raw-event window; omitted `before` and `after` values mean target-only. Model-facing filters use flat snake-case fields. Timestamps are timezone-qualified ISO 8601 strings at the tool boundary, convert to inclusive epoch-millisecond ranges for the service, and render as UTC ISO 8601. List values are ORed inside one filter while separate filters are ANDed. Requested parent ids are deduplicated and authority-filtered before FTS, so only parents in the caller workspace enter the provider clause; missing and cross-workspace guesses behave identically, while the root-session marker remains independently ORed into that clause. Event type strings remain open because `SessionEventMap` is merge-extensible; availability and event surface use closed values. diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index eae100375f..c8a0c70789 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -12,6 +12,8 @@ Status: implemented `@deepseek-ai/dsh-tool-session-query` 是 `ctx.sessionQuery` 面向模型的消费者。它注册五个职责单一的只读工具:`session_search`、`session_event_search`、`session_trace`、`session_event_trace` 和 `session_event_read`。该包依赖接口而非 SQLite 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。 +该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`input.ts` 负责模型 schema、规范化与过滤条件构造;`service-boundary.ts` 包含提供方调用与面向模型的安全错误转换;`workspace-access.ts` 负责调用者身份、工作区授权、标题访问与谱系投影;`operations.ts` 编排五个服务工作流;`presentation.ts` 渲染工具结果与调用卡片。这样可让策略留在其所属层,同时不改变包契约。 + `session_search` 按会话聚合全文匹配,并公开带类型的会话与事件元数据过滤条件。`session_event_search` 搜索一个会话,默认目标为调用者的当前会话。`session_trace` 返回完整的已授权祖先链与递归后代树。`session_event_trace` 返回一个事件所有已知的位置替换关系与直接来源关系。`session_event_read` 以未删节 JSON 返回准确的目标事件,并可选择汇总一个有界的原始事件窗口;省略 `before` 与 `after` 时只返回目标。 面向模型的过滤条件使用扁平的 snake-case 字段。工具边界上的时间戳采用带时区的 ISO 8601 字符串,转换为服务使用的闭区间毫秒时间戳,并以 UTC ISO 8601 渲染。同一个过滤条件中的列表值按 OR 组合,不同过滤条件按 AND 组合。请求的父会话 id 会在 FTS 之前去重并按权限过滤,因此只有调用者工作区中的父会话会进入提供方条件;缺失与跨工作区的猜测具有相同行为,而根会话标记仍会独立按 OR 加入该条件。由于 `SessionEventMap` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a1b9d4449f..bb6690bfb1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1560,7 +1560,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:52`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:29`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index e05ab89218..d6eb659b4d 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -6,35 +6,12 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { HarnessError } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { - SessionId, - type SessionEvent, - type SessionEventType, - type SessionHeader, - type SessionId as SessionIdValue, -} from '@deepseek-ai/dsh-session' -import { - SessionQueryError, - extractSessionEventText, - type SessionAvailability, - type SessionEventMetadataFilter, - type SessionEventSearchPage, - type SessionEventSearchHit, - type SessionEventSurface, - type SessionEventTraceObservation, - type SessionEventWindow, - type SessionLineageNode, - type SessionLineageTrace, - type SessionRecord, - type SessionResultFilter, - type SessionQueryErrorCode, - type SessionSearchCursor, - type SessionSearchHit, -} from '@deepseek-ai/dsh-session-query' -import { defineTool, type GenericCallView, type ToolRunContext } from '@deepseek-ai/dsh-tools' +import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' +import { toolInput } from './input.ts' +import { operations } from './operations.ts' +import { presentation } from './presentation.ts' /** Cordis plugin name used by Loader diagnostics. */ export const name = 'tool-session-query' @@ -67,126 +44,6 @@ interface ResolvedConfig { readonly searchTimeoutMs: number } -interface SessionSearchArgs { - query: string - session_ids?: string[] - created_at_from?: string - created_at_to?: string - parent_session_ids?: string[] - include_root_sessions?: boolean - availability?: SessionAvailability[] - event_seq_from?: number - event_seq_to?: number - event_time_from?: string - event_time_to?: string - event_types?: string[] - event_surfaces?: SessionEventSurface[] -} - -interface EventSearchArgs { - session_id?: string - query: string - seq_from?: number - seq_to?: number - time_from?: string - time_to?: string - event_types?: string[] - surfaces?: SessionEventSurface[] -} - -interface SessionTargetArgs { - session_id?: string -} - -interface EventTargetArgs extends SessionTargetArgs { - seq: number -} - -interface EventReadArgs extends EventTargetArgs { - before?: number - after?: number -} - -interface Caller { - readonly id: SessionIdValue - readonly header: SessionHeader - readonly events: readonly SessionEvent[] -} - -interface TitleView { - readonly text: string - readonly unavailableCode?: string -} - -interface CompleteTitleMap extends ReadonlyMap { - get(id: SessionIdValue): TitleView -} - -interface SearchCollection { - readonly items: T[] - readonly capped: boolean -} - -interface AuthorizedDescendant { - readonly record: SessionRecord - readonly descendants: Array -} - -interface DescendantProjectionFrame { - readonly node: SessionLineageNode - readonly target: Array - readonly next: DescendantProjectionFrame | undefined -} - -interface DescendantVisit { - readonly node: AuthorizedDescendant | null - readonly depth: number - readonly next: DescendantVisit | undefined -} - -const SESSION_SEARCH_PARAMETERS = { - query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, - session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, - created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' }, - created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' }, - parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' }, - include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' }, - availability: { - type: 'array', - items: { type: 'string', enum: ['live', 'persisted'] }, - description: 'Require at least one selected source availability.', - }, - event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, - event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, - event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, - event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, - event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, - event_surfaces: { - type: 'array', - items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, - description: 'Event surfaces to include.', - }, -} as const - -const EVENT_SEARCH_PARAMETERS = { - session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, - query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' }, - seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, - seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, - time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, - time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, - event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, - surfaces: { - type: 'array', - items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, - description: 'Event surfaces to include.', - }, -} as const - -const TARGET_SESSION_PARAMETER = { - session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, -} as const - const TEXT_OUTPUT = { schema: { type: 'string' as const }, render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }], @@ -197,76 +54,6 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' -interface ModelSafeServiceFailure { - readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' - readonly message: string -} - -const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' - -const SAFE_SESSION_QUERY_FAILURES = { - SESSION_QUERY_ABORTED: { - code: 'SESSION_QUERY_ABORTED', - message: 'session query was cancelled', - }, - SESSION_QUERY_EVENT_NOT_FOUND: { - code: 'SESSION_QUERY_EVENT_NOT_FOUND', - message: 'session event was not found', - }, - SESSION_QUERY_INDEX_FAILED: { - code: 'SESSION_QUERY_INDEX_FAILED', - message: 'session search index is unavailable', - }, - SESSION_QUERY_INVALID_CONFIG: { - code: 'SESSION_QUERY_TOOL_FAILED', - message: 'session query operation failed', - }, - SESSION_QUERY_INVALID_CURSOR: { - code: 'SESSION_QUERY_INVALID_CURSOR', - message: 'session search continuation is invalid', - }, - SESSION_QUERY_INVALID_FILTER: { - code: 'SESSION_QUERY_INVALID_FILTER', - message: 'session query filters were rejected', - }, - SESSION_QUERY_INVALID_LIMIT: { - code: 'SESSION_QUERY_INVALID_LIMIT', - message: 'session query result limit was rejected', - }, - SESSION_QUERY_INVALID_QUERY: { - code: 'SESSION_QUERY_INVALID_QUERY', - message: 'session query was rejected', - }, - SESSION_QUERY_INVALID_LINEAGE: { - code: 'SESSION_QUERY_INVALID_LINEAGE', - message: 'session lineage is invalid', - }, - SESSION_QUERY_INVALID_SURFACE: { - code: 'SESSION_QUERY_INVALID_SURFACE', - message: 'session event history is invalid', - }, - SESSION_QUERY_INVALID_WINDOW: { - code: 'SESSION_QUERY_INVALID_WINDOW', - message: 'session event window is invalid', - }, - SESSION_QUERY_PERSISTENCE_FAILED: { - code: 'SESSION_QUERY_PERSISTENCE_FAILED', - message: 'session history storage is unavailable', - }, - SESSION_QUERY_SESSION_NOT_FOUND: { - code: 'SESSION_QUERY_SESSION_NOT_FOUND', - message: 'session was not found', - }, - SESSION_QUERY_STALE_CURSOR: { - code: 'SESSION_QUERY_STALE_CURSOR', - message: 'session history changed while paging; retry the complete search call', - }, - SESSION_QUERY_SOURCE_CONFLICT: { - code: 'SESSION_QUERY_TOOL_FAILED', - message: 'session query operation failed', - }, -} satisfies Record - /** Register all five tools and their shared model guidance. */ export function apply(ctx: Context, config: Config): void { const resolved = resolveConfig(config) @@ -279,59 +66,59 @@ export function apply(ctx: Context, config: Config): void { ctx.tools.register(defineTool({ name: 'session_search', description: 'Search prior sessions in the caller workspace and return the strongest matching event from each session.', - parameters: SESSION_SEARCH_PARAMETERS, + parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), - presentCall: presentSessionSearchCall, + execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentation.presentSessionSearchCall, })) ctx.tools.register(defineTool({ name: 'session_event_search', description: 'Search prior events in one authorized session; the current session excludes the step performing this call.', - parameters: EVENT_SEARCH_PARAMETERS, + parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => executeEventSearch(ctx, args, exec, resolved.maxSearchResults), - presentCall: presentEventSearchCall, + execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults), + presentCall: presentation.presentEventSearchCall, })) ctx.tools.register(defineTool({ name: 'session_trace', description: 'Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.', - parameters: TARGET_SESSION_PARAMETER, + parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeSessionTrace(ctx, args, exec), - presentCall: presentSessionTraceCall, + execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec), + presentCall: presentation.presentSessionTraceCall, })) ctx.tools.register(defineTool({ name: 'session_event_trace', description: 'Read every direct replacement and provenance relationship for one event in an authorized session.', parameters: { - ...TARGET_SESSION_PARAMETER, + ...toolInput.targetSessionParameter, seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeEventTrace(ctx, args, exec), - presentCall: args => presentEventTargetCall('Trace event', args), + execute: (args, exec) => operations.executeEventTrace(ctx, args, exec), + presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) ctx.tools.register(defineTool({ name: 'session_event_read', description: 'Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.', parameters: { - ...TARGET_SESSION_PARAMETER, + ...toolInput.targetSessionParameter, seq: { type: 'integer', required: true, description: 'Target event sequence number.' }, before: { type: 'integer', description: 'Number of preceding raw events to summarize. Omit for none.' }, after: { type: 'integer', description: 'Number of following raw events to summarize. Omit for none.' }, }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => executeEventRead(ctx, args, exec), - presentCall: args => presentEventTargetCall('Read event', args), + execute: (args, exec) => operations.executeEventRead(ctx, args, exec), + presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } @@ -348,899 +135,3 @@ function resolveConfig(config: Config): ResolvedConfig { } return { maxSearchResults, searchTimeoutMs } } - -function callerOf(exec: ToolRunContext): Caller { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - events: agent.session.events, - } -} - -function targetId(args: SessionTargetArgs, caller: Caller): SessionIdValue { - return args.session_id === undefined ? caller.id : SessionId(args.session_id) -} - -async function authorizeTarget( - ctx: Context, - caller: Caller, - target: SessionIdValue, - signal: AbortSignal, -): Promise { - if (target === caller.id) return - const cwd = caller.header.cwd - if (cwd === undefined) throw unauthorizedTarget() - const records = await sessionQueryCall(ctx, signal, 'target authorization', () => - ctx.sessionQuery.filterSessions([ - { kind: 'id', values: [target] }, - { kind: 'cwd', values: [cwd] }, - ], signal)) - if (records.length !== 1) throw unauthorizedTarget() -} - -function unauthorizedTarget(): HarnessError { - return new HarnessError( - 'session target is outside the caller workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) -} - -async function sessionQueryCall( - ctx: Context, - signal: AbortSignal, - operation: string, - call: () => Promise, -): Promise { - signal.throwIfAborted() - try { - const value = await call() - signal.throwIfAborted() - return value - } catch (error: unknown) { - signal.throwIfAborted() - throw sanitizeSessionQueryError(ctx, operation, error) - } -} - -function sanitizeSessionQueryError( - ctx: Context, - operation: string, - error: unknown, -): HarnessError { - const generic = genericSessionQueryFailure() - const diagnostic = fullError(error) - try { - ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) - if (error instanceof SessionQueryError) { - const code: unknown = error.code - const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) - ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] - : undefined - if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { - return new SessionQueryError(failure.message, failure.code) - } - } - if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { - return unauthorizedTarget() - } - } catch { - return generic - } - return generic -} - -function genericSessionQueryFailure(): HarnessError { - return new HarnessError( - 'session query operation failed', - 'SESSION_QUERY_TOOL_FAILED', - ) -} - -async function executeSessionSearch( - ctx: Context, - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = callerOf(exec) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = normalizeQuery(args.query) - const sessionFilters = buildSessionFilters(args) - const eventFilters = buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => sessionQueryCall(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && recordAuthorized(hit, caller), - ) - - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionIdValue => id !== undefined) - const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await readTitles(ctx, caller, collected.items.map(hit => hit.header.id), exec.signal) - return formatSessionSearch(collected, titles, authorizedParents) -} - -async function executeEventSearch( - ctx: Context, - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = normalizeQuery(args.query) - const range = sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStart = caller.events.findLast(event => event.type === 'step/start') - if (stepStart === undefined) { - throw new HarnessError( - 'current-session search requires an active step boundary', - 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', - ) - } - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) - } - const title = await readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await sessionQueryCall(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return formatEventSearch(sessionId, title, collected) -} - -async function executeSessionTrace( - ctx: Context, - args: SessionTargetArgs, - exec: ToolRunContext, -): Promise { - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await sessionQueryCall(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break - } - ancestors.push(ancestor) - } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...descendantIds(descendants), - ] - const titles = await readTitles(ctx, caller, visibleIds, exec.signal) - return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) -} - -async function executeEventTrace( - ctx: Context, - args: EventTargetArgs, - exec: ToolRunContext, -): Promise { - assertNonNegativeSafeInteger('seq', args.seq) - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await sessionQueryCall(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await readTitle(ctx, caller, sessionId, exec.signal) - return formatEventTrace(sessionId, title, trace) -} - -async function executeEventRead( - ctx: Context, - args: EventReadArgs, - exec: ToolRunContext, -): Promise { - assertNonNegativeSafeInteger('seq', args.seq) - if (args.before !== undefined) assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) assertNonNegativeSafeInteger('after', args.after) - const caller = callerOf(exec) - const sessionId = targetId(args, caller) - await authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await sessionQueryCall(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq: args.seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await readTitle(ctx, caller, sessionId, exec.signal) - return formatEventRead(sessionId, title, window) -} - -function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { - const filters: SessionResultFilter[] = [] - if (args.session_ids !== undefined) { - assertNonEmptyArray('session_ids', args.session_ids) - filters.push({ kind: 'id', values: args.session_ids.map(SessionId) }) - } - const created = timestampRange('created_at', args.created_at_from, args.created_at_to) - if (created !== undefined) filters.push({ kind: 'created-at', ...created }) - if (args.availability !== undefined) { - assertNonEmptyArray('availability', args.availability) - filters.push({ kind: 'availability', values: args.availability }) - } - return filters -} - -function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { - if (values === undefined) return undefined - assertNonEmptyArray('parent_session_ids', values) - return [...new Set(values.map(SessionId))] -} - -interface EventFilterInput { - readonly seqFrom?: number | undefined - readonly seqTo?: number | undefined - readonly timeFrom?: string | undefined - readonly timeTo?: string | undefined - readonly eventTypes?: string[] | undefined - readonly surfaces?: SessionEventSurface[] | undefined -} - -function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] { - const filters: SessionEventMetadataFilter[] = [] - const seq = sequenceRange(input.seqFrom, input.seqTo) - if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq }) - const time = timestampRange('time', input.timeFrom, input.timeTo) - if (time !== undefined) filters.push({ kind: 'time', ...time }) - if (input.eventTypes !== undefined) { - assertNonEmptyArray('event_types', input.eventTypes) - filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] }) - } - if (input.surfaces !== undefined) { - assertNonEmptyArray('surfaces', input.surfaces) - filters.push({ kind: 'surface', values: input.surfaces }) - } - return filters -} - -function normalizeQuery(value: string): string { - const query = value.trim().replace(/\s+/gu, ' ') - if (query.length === 0) { - throw new SessionQueryError( - 'session-search query must contain non-whitespace text', - 'SESSION_QUERY_INVALID_QUERY', - ) - } - if (query.includes('\0')) { - throw new SessionQueryError( - 'session-search query must not contain NUL', - 'SESSION_QUERY_INVALID_QUERY', - ) - } - return query -} - -function sequenceRange( - from: number | undefined, - to: number | undefined, -): { from?: number; to?: number } { - if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from) - if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to) - if (from !== undefined && to !== undefined && from > to) { - throw invalidRange('sequence', 'from must be less than or equal to to') - } - return { - ...from === undefined ? {} : { from }, - ...to === undefined ? {} : { to }, - } -} - -function timestampRange( - name: string, - from: string | undefined, - to: string | undefined, -): { from?: number; to?: number } | undefined { - if (from === undefined && to === undefined) return undefined - const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) - const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) - if ( - fromTimestamp !== undefined - && toTimestamp !== undefined - && compareTimestamps(fromTimestamp, toTimestamp) > 0 - ) { - throw invalidRange(name, 'from must be less than or equal to to') - } - return { - ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, - ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, - } -} - -const ISO_TIMESTAMP = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ - -interface ExactTimestamp { - readonly millisecond: number - /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ - readonly remainder: string -} - -function parseIsoTimestamp(name: string, value: string): ExactTimestamp { - const match = ISO_TIMESTAMP.exec(value) - if (match === null) { - throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') - } - const year = Number(match[1]) - const month = Number(match[2]) - const day = Number(match[3]) - const hour = Number(match[4]) - const minute = Number(match[5]) - const second = Number(match[6] ?? 0) - const offsetHour = Number(match[10] ?? 0) - const offsetMinute = Number(match[11] ?? 0) - if ( - month < 1 || month > 12 - || day < 1 || day > daysInMonth(year, month) - || hour > 23 || minute > 59 || second > 59 - || offsetHour > 23 || offsetMinute > 59 - ) { - throw invalidRange(name, 'must be a valid ISO 8601 timestamp') - } - const fraction = match[7] ?? '' - const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') - const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` - + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` - const timestamp = Date.parse(normalized) - if (!Number.isSafeInteger(timestamp)) { - throw invalidRange(name, 'must be a valid ISO 8601 timestamp') - } - return { - millisecond: timestamp, - remainder: fraction.slice(3).replace(/0+$/u, ''), - } -} - -function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { - if (left.millisecond !== right.millisecond) { - return left.millisecond < right.millisecond ? -1 : 1 - } - const length = Math.max(left.remainder.length, right.remainder.length) - for (let index = 0; index < length; index += 1) { - const leftDigit = left.remainder[index] ?? '0' - const rightDigit = right.remainder[index] ?? '0' - if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 - } - return 0 -} - -function timestampLowerBound(timestamp: ExactTimestamp): number { - return timestamp.remainder.length === 0 - ? timestamp.millisecond - : nextUpFinite(timestamp.millisecond) -} - -function timestampUpperBound(timestamp: ExactTimestamp): number { - return timestamp.remainder.length === 0 - ? timestamp.millisecond - : nextDownFinite(timestamp.millisecond + 1) -} - -/** Return the adjacent IEEE-754 value toward positive infinity for a finite input. */ -function nextUpFinite(value: number): number { - if (value === 0) return Number.MIN_VALUE - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, value) - const bits = view.getBigUint64(0) - view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) - return view.getFloat64(0) -} - -/** Return the adjacent IEEE-754 value toward negative infinity for a finite input. */ -function nextDownFinite(value: number): number { - if (value === 0) return -Number.MIN_VALUE - const view = new DataView(new ArrayBuffer(8)) - view.setFloat64(0, value) - const bits = view.getBigUint64(0) - view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) - return view.getFloat64(0) -} - -function daysInMonth(year: number, month: number): number { - if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 - return [4, 6, 9, 11].includes(month) ? 30 : 31 -} - -function invalidRange(name: string, detail: string): SessionQueryError { - return new SessionQueryError( - `session ${name} range ${detail}`, - 'SESSION_QUERY_INVALID_FILTER', - ) -} - -function assertNonNegativeSafeInteger(name: string, value: number): void { - if (!Number.isSafeInteger(value) || value < 0) { - throw new SessionQueryError( - `${name} must be a non-negative safe integer`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - -function assertNonEmptyArray(name: string, values: readonly unknown[]): void { - if (values.length === 0) { - throw new SessionQueryError( - `${name} must contain at least one value when supplied`, - 'SESSION_QUERY_INVALID_FILTER', - ) - } -} - -async function collectPages( - maxResults: number, - signal: AbortSignal, - request: (cursor?: SessionSearchCursor) => Promise<{ - readonly items: readonly T[] - readonly nextCursor?: SessionSearchCursor - }>, - accept: (item: T) => boolean, -): Promise> { - const items: T[] = [] - const seen = new Set() - let cursor: SessionSearchCursor | undefined - while (true) { - signal.throwIfAborted() - const page = await request(cursor) - signal.throwIfAborted() - for (const item of page.items) { - if (!accept(item)) continue - if (items.length === maxResults) { - return { items, capped: true } - } - items.push(item) - } - if (page.nextCursor === undefined) return { items, capped: false } - if (seen.has(page.nextCursor)) { - throw new SessionQueryError( - 'session-search provider repeated a continuation cursor', - 'SESSION_QUERY_INVALID_CURSOR', - ) - } - seen.add(page.nextCursor) - cursor = page.nextCursor - } -} - -function recordAuthorized(record: SessionRecord, caller: Caller): boolean { - return headerAuthorized(record.header, caller) -} - -function headerAuthorized(header: SessionHeader, caller: Caller): boolean { - if (header.id === caller.id) return header.cwd === caller.header.cwd - return caller.header.cwd !== undefined && header.cwd === caller.header.cwd -} - -function assertObservedTargetAuthorized( - caller: Caller, - target: SessionIdValue, - observed: SessionHeader, -): void { - if (observed.id !== target || !headerAuthorized(observed, caller)) throw unauthorizedTarget() -} - -async function authorizeSessionIds( - ctx: Context, - caller: Caller, - ids: readonly SessionIdValue[], - signal: AbortSignal, -): Promise> { - const unique = [...new Set(ids)] - const authorized = new Set() - if (unique.includes(caller.id)) authorized.add(caller.id) - const cwd = caller.header.cwd - const other = unique.filter(id => id !== caller.id) - if (cwd === undefined || other.length === 0) return authorized - const records = await sessionQueryCall(ctx, signal, 'session-id authorization', () => - ctx.sessionQuery.filterSessions([ - { kind: 'id', values: other }, - { kind: 'cwd', values: [cwd] }, - ], signal)) - const requested = new Set(other) - for (const record of records) { - if (requested.has(record.header.id) && recordAuthorized(record, caller)) { - authorized.add(record.header.id) - } - } - return authorized -} - -async function readTitles( - ctx: Context, - caller: Caller, - ids: readonly SessionIdValue[], - signal: AbortSignal, -): Promise { - const result = new Map() - const observations = await sessionQueryCall(ctx, signal, 'title observation', () => - ctx.sessionQuery.readTitleSnapshots(ids, signal)) - for (const observation of observations) { - if (observation.status === 'rejected') { - result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) - continue - } - assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) - result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' }) - } - return result as CompleteTitleMap -} - -async function readTitle( - ctx: Context, - caller: Caller, - id: SessionIdValue, - signal: AbortSignal, -): Promise { - return (await readTitles(ctx, caller, [id], signal)).get(id) -} - -function unavailableTitle( - ctx: Context, - error: unknown, -): TitleView { - const sanitized = sanitizeSessionQueryError(ctx, 'title observation item', error) - if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized - return { text: 'untitled', unavailableCode: sanitized.code } -} - -function fullError(error: unknown): string { - try { - return renderFullError(error) - } catch { - return UNPRINTABLE_SERVICE_ERROR - } -} - -function renderFullError(error: unknown): string { - if (!(error instanceof Error)) return String(error) - const diagnostics: string[] = [] - const seen = new Set() - let current: unknown = error - while (current instanceof Error && !seen.has(current)) { - seen.add(current) - diagnostics.push(current.stack ?? String(current)) - current = current.cause - } - /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ - if (current instanceof Error) diagnostics.push('[circular error cause]') - else if (current !== undefined) diagnostics.push(renderFullError(current)) - return diagnostics.join('\nCaused by: ') -} - -function authorizeDescendants( - nodes: readonly SessionLineageNode[], - caller: Caller, -): Array { - const result: Array = [] - let pending: DescendantProjectionFrame | undefined - for (const node of [...nodes].reverse()) { - pending = { node, target: result, next: pending } - } - while (pending !== undefined) { - const current = pending - pending = current.next - if (!recordAuthorized(current.node.session, caller)) { - current.target.push(null) - continue - } - const projected: AuthorizedDescendant = { - record: current.node.session, - descendants: [], - } - current.target.push(projected) - for (const child of [...current.node.descendants].reverse()) { - pending = { - node: child, - target: projected.descendants, - next: pending, - } - } - } - return result -} - -function * visitDescendants( - nodes: readonly (AuthorizedDescendant | null)[], -): Generator { - let pending: DescendantVisit | undefined - for (const node of [...nodes].reverse()) { - pending = { node, depth: 0, next: pending } - } - while (pending !== undefined) { - const current = pending - pending = current.next - yield current - if (current.node === null) continue - for (const child of [...current.node.descendants].reverse()) { - pending = { - node: child, - depth: current.depth + 1, - next: pending, - } - } - } -} - -function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { - const ids: SessionIdValue[] = [] - for (const { node } of visitDescendants(nodes)) { - if (node !== null) ids.push(node.record.header.id) - } - return ids -} - -function titleText(view: TitleView): string { - return view.unavailableCode === undefined - ? view.text - : `${view.text} (title unavailable: ${view.unavailableCode})` -} - -function formatSessionSearch( - collected: SearchCollection, - titles: CompleteTitleMap, - authorizedParents: ReadonlySet, -): string { - if (collected.items.length === 0) return formatEmptySessionSearch() - const lines = [`Session search results (${collected.items.length}):`] - for (const [index, hit] of collected.items.entries()) { - const parent = hit.header.parentSession === undefined - ? 'root' - : authorizedParents.has(hit.header.parentSession) - ? hit.header.parentSession - : '[outside workspace]' - const availability = [ - hit.live ? 'live' : undefined, - hit.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' - lines.push( - '', - `${index + 1}. Session ${hit.header.id} — ${titleText(titles.get(hit.header.id))}`, - ` Created: ${formatTime(hit.header.createdAt)}`, - ` Parent: ${parent}`, - ` Availability: ${availability}`, - ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`, - ` Snippet: ${hit.bestMatch.snippet}`, - ) - } - if (collected.capped) { - lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') - } - return lines.join('\n') -} - -function formatEmptySessionSearch(): string { - return 'No prior session matches found.' -} - -function formatEventSearch( - sessionId: SessionIdValue, - title: TitleView, - collected: SearchCollection, -): string { - const lines = [`Session ${sessionId} — ${titleText(title)}`] - if (collected.items.length === 0) { - lines.push('', 'No prior event matches found.') - return lines.join('\n') - } - lines.push('', `Event search results (${collected.items.length}):`) - for (const [index, hit] of collected.items.entries()) { - lines.push( - `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`, - ` Snippet: ${hit.snippet}`, - ) - } - if (collected.capped) { - lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') - } - return lines.join('\n') -} - -function formatSessionTrace( - trace: SessionLineageTrace, - ancestors: readonly SessionRecord[], - ancestorBoundary: boolean, - descendants: readonly (AuthorizedDescendant | null)[], - titles: CompleteTitleMap, -): string { - const lines = [ - `Session ${trace.target.header.id} — ${titleText(titles.get(trace.target.header.id))}`, - `Created: ${formatTime(trace.target.header.createdAt)}`, - `Availability: ${availabilityText(trace.target)}`, - '', - 'Ancestors (nearest first):', - ] - if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)') - for (const record of ancestors) { - lines.push(`- ${record.header.id} — ${titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`) - } - if (ancestorBoundary) lines.push('- [outside workspace boundary]') - lines.push('', 'Descendants:') - if (descendants.length === 0) lines.push('- none') - else renderDescendants(lines, descendants, titles) - return lines.join('\n') -} - -function renderDescendants( - lines: string[], - nodes: readonly (AuthorizedDescendant | null)[], - titles: CompleteTitleMap, -): void { - for (const { node, depth } of visitDescendants(nodes)) { - const indent = ' '.repeat(depth) - if (node === null) { - lines.push(`${indent}- [outside workspace subtree]`) - continue - } - const id = node.record.header.id - lines.push(`${indent}- ${id} — ${titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) - } -} - -function formatEventTrace( - sessionId: SessionIdValue, - title: TitleView, - trace: SessionEventTraceObservation, -): string { - return [ - `Session ${sessionId} — ${titleText(title)}`, - `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`, - `Replaced by: ${trace.replacedBy ?? 'none'}`, - `Replacement chain: ${seqList(trace.replacementChain)}`, - `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`, - `Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`, - `Direct derived events: ${seqList(trace.derivedEventSeqs)}`, - ].join('\n') -} - -function formatEventRead( - sessionId: SessionIdValue, - title: TitleView, - window: SessionEventWindow, -): string { - const before = window.events.filter(event => event.seq < window.target.seq) - const after = window.events.filter(event => event.seq > window.target.seq) - const lines = [ - `Session ${sessionId} — ${titleText(title)}`, - `Target event seq ${window.target.seq}:`, - '```json', - JSON.stringify(window.target, null, 2), - '```', - ] - if (before.length > 0) { - lines.push('', 'Before:') - for (const event of before) lines.push(formatNeighbor(event)) - } - if (after.length > 0) { - lines.push('', 'After:') - for (const event of after) lines.push(formatNeighbor(event)) - } - return lines.join('\n') -} - -function formatNeighbor(event: SessionEvent): string { - const text = extractSessionEventText(event) - return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}` - + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`) -} - -function availabilityText(record: SessionRecord): string { - return [ - record.live ? 'live' : undefined, - record.persisted ? 'persisted' : undefined, - ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' -} - -function seqList(values: readonly number[]): string { - return values.length === 0 ? 'none' : values.join(', ') -} - -function formatTime(value: number): string { - return new Date(value).toISOString() -} - -function presentSessionSearchCall(args: SessionSearchArgs): GenericCallView { - return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query } -} - -function presentEventSearchCall(args: EventSearchArgs): GenericCallView { - return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query } -} - -function presentSessionTraceCall(args: SessionTargetArgs): GenericCallView { - return { - card: 'generic', - kind: 'read', - title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`, - ...args.session_id === undefined ? {} : { rawInput: args.session_id }, - } -} - -function presentEventTargetCall( - action: string, - args: EventTargetArgs, -): GenericCallView { - return { - card: 'generic', - kind: 'read', - title: `${action} ${args.seq}`, - rawInput: { - ...args.session_id === undefined ? {} : { session_id: args.session_id }, - seq: args.seq, - }, - } -} diff --git a/packages/session-query/tool-session-query/src/input.ts b/packages/session-query/tool-session-query/src/input.ts new file mode 100644 index 0000000000..4b045ea72d --- /dev/null +++ b/packages/session-query/tool-session-query/src/input.ts @@ -0,0 +1,307 @@ +/** + * Model argument schemas, normalization, and filter construction. + * + * @module @deepseek-ai/dsh-tool-session-query/input + */ + +import { + SessionId, + type SessionEventType, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import { + SessionQueryError, + type SessionAvailability, + type SessionEventMetadataFilter, + type SessionEventSurface, + type SessionResultFilter, +} from '@deepseek-ai/dsh-session-query' + +interface SessionSearchArgs { + query: string + session_ids?: string[] + created_at_from?: string + created_at_to?: string + parent_session_ids?: string[] + include_root_sessions?: boolean + availability?: SessionAvailability[] + event_seq_from?: number + event_seq_to?: number + event_time_from?: string + event_time_to?: string + event_types?: string[] + event_surfaces?: SessionEventSurface[] +} + +interface EventFilterInput { + readonly seqFrom?: number | undefined + readonly seqTo?: number | undefined + readonly timeFrom?: string | undefined + readonly timeTo?: string | undefined + readonly eventTypes?: string[] | undefined + readonly surfaces?: SessionEventSurface[] | undefined +} + +const sessionSearchParameters = { + query: { type: 'string', required: true, description: 'Literal full-text query over prior session history.' }, + session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional session ids to include.' }, + created_at_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time lower bound.' }, + created_at_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 creation-time upper bound.' }, + parent_session_ids: { type: 'array', items: { type: 'string' }, description: 'Optional direct parent session ids.' }, + include_root_sessions: { type: 'boolean', description: 'Include sessions with no parent in the parent filter.' }, + availability: { + type: 'array', + items: { type: 'string', enum: ['live', 'persisted'] }, + description: 'Require at least one selected source availability.', + }, + event_seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + event_seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + event_time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + event_time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + event_surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const eventSearchParameters = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, + query: { type: 'string', required: true, description: 'Literal full-text query over the target session.' }, + seq_from: { type: 'integer', description: 'Inclusive event sequence lower bound.' }, + seq_to: { type: 'integer', description: 'Inclusive event sequence upper bound.' }, + time_from: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time lower bound.' }, + time_to: { type: 'string', description: 'Inclusive timezone-qualified ISO 8601 event-time upper bound.' }, + event_types: { type: 'array', items: { type: 'string' }, description: 'Event types to include.' }, + surfaces: { + type: 'array', + items: { type: 'string', enum: ['current', 'shadowed', 'log-only'] }, + description: 'Event surfaces to include.', + }, +} as const + +const targetSessionParameter = { + session_id: { type: 'string', description: 'Target session id. Omit for the current session.' }, +} as const + +function buildSessionFilters(args: SessionSearchArgs): SessionResultFilter[] { + const filters: SessionResultFilter[] = [] + if (args.session_ids !== undefined) { + assertNonEmptyArray('session_ids', args.session_ids) + filters.push({ kind: 'id', values: args.session_ids.map(SessionId) }) + } + const created = timestampRange('created_at', args.created_at_from, args.created_at_to) + if (created !== undefined) filters.push({ kind: 'created-at', ...created }) + if (args.availability !== undefined) { + assertNonEmptyArray('availability', args.availability) + filters.push({ kind: 'availability', values: args.availability }) + } + return filters +} + +function materializeParentSessionIds(values: readonly string[] | undefined): SessionIdValue[] | undefined { + if (values === undefined) return undefined + assertNonEmptyArray('parent_session_ids', values) + return [...new Set(values.map(SessionId))] +} + +function buildEventFilters(input: EventFilterInput): SessionEventMetadataFilter[] { + const filters: SessionEventMetadataFilter[] = [] + const seq = sequenceRange(input.seqFrom, input.seqTo) + if (seq.from !== undefined || seq.to !== undefined) filters.push({ kind: 'seq', ...seq }) + const time = timestampRange('time', input.timeFrom, input.timeTo) + if (time !== undefined) filters.push({ kind: 'time', ...time }) + if (input.eventTypes !== undefined) { + assertNonEmptyArray('event_types', input.eventTypes) + filters.push({ kind: 'type', values: input.eventTypes as SessionEventType[] }) + } + if (input.surfaces !== undefined) { + assertNonEmptyArray('surfaces', input.surfaces) + filters.push({ kind: 'surface', values: input.surfaces }) + } + return filters +} + +function normalizeQuery(value: string): string { + const query = value.trim().replace(/\s+/gu, ' ') + if (query.length === 0) { + throw new SessionQueryError( + 'session-search query must contain non-whitespace text', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + if (query.includes('\0')) { + throw new SessionQueryError( + 'session-search query must not contain NUL', + 'SESSION_QUERY_INVALID_QUERY', + ) + } + return query +} + +function sequenceRange( + from: number | undefined, + to: number | undefined, +): { from?: number; to?: number } { + if (from !== undefined) assertNonNegativeSafeInteger('sequence lower bound', from) + if (to !== undefined) assertNonNegativeSafeInteger('sequence upper bound', to) + if (from !== undefined && to !== undefined && from > to) { + throw invalidRange('sequence', 'from must be less than or equal to to') + } + return { + ...from === undefined ? {} : { from }, + ...to === undefined ? {} : { to }, + } +} + +function timestampRange( + name: string, + from: string | undefined, + to: string | undefined, +): { from?: number; to?: number } | undefined { + if (from === undefined && to === undefined) return undefined + const fromTimestamp = from === undefined ? undefined : parseIsoTimestamp(`${name}_from`, from) + const toTimestamp = to === undefined ? undefined : parseIsoTimestamp(`${name}_to`, to) + if ( + fromTimestamp !== undefined + && toTimestamp !== undefined + && compareTimestamps(fromTimestamp, toTimestamp) > 0 + ) { + throw invalidRange(name, 'from must be less than or equal to to') + } + return { + ...fromTimestamp === undefined ? {} : { from: timestampLowerBound(fromTimestamp) }, + ...toTimestamp === undefined ? {} : { to: timestampUpperBound(toTimestamp) }, + } +} + +const ISO_TIMESTAMP = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d+))?)?(Z|([+-])(\d{2}):(\d{2}))$/ + +interface ExactTimestamp { + readonly millisecond: number + /** Canonical decimal digits strictly below one millisecond; no trailing zeroes. */ + readonly remainder: string +} + +function parseIsoTimestamp(name: string, value: string): ExactTimestamp { + const match = ISO_TIMESTAMP.exec(value) + if (match === null) { + throw invalidRange(name, 'must be an ISO 8601 timestamp with Z or a numeric offset') + } + const year = Number(match[1]) + const month = Number(match[2]) + const day = Number(match[3]) + const hour = Number(match[4]) + const minute = Number(match[5]) + const second = Number(match[6] ?? 0) + const offsetHour = Number(match[10] ?? 0) + const offsetMinute = Number(match[11] ?? 0) + if ( + month < 1 || month > 12 + || day < 1 || day > daysInMonth(year, month) + || hour > 23 || minute > 59 || second > 59 + || offsetHour > 23 || offsetMinute > 59 + ) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + const fraction = match[7] ?? '' + const millisecondDigits = fraction.slice(0, 3).padEnd(3, '0') + const normalized = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}` + + `:${match[6] ?? '00'}.${millisecondDigits}${match[8]}` + const timestamp = Date.parse(normalized) + if (!Number.isSafeInteger(timestamp)) { + throw invalidRange(name, 'must be a valid ISO 8601 timestamp') + } + return { + millisecond: timestamp, + remainder: fraction.slice(3).replace(/0+$/u, ''), + } +} + +function compareTimestamps(left: ExactTimestamp, right: ExactTimestamp): number { + if (left.millisecond !== right.millisecond) { + return left.millisecond < right.millisecond ? -1 : 1 + } + const length = Math.max(left.remainder.length, right.remainder.length) + for (let index = 0; index < length; index += 1) { + const leftDigit = left.remainder[index] ?? '0' + const rightDigit = right.remainder[index] ?? '0' + if (leftDigit !== rightDigit) return leftDigit < rightDigit ? -1 : 1 + } + return 0 +} + +function timestampLowerBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextUpFinite(timestamp.millisecond) +} + +function timestampUpperBound(timestamp: ExactTimestamp): number { + return timestamp.remainder.length === 0 + ? timestamp.millisecond + : nextDownFinite(timestamp.millisecond + 1) +} + +function nextUpFinite(value: number): number { + if (value === 0) return Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n) + return view.getFloat64(0) +} + +function nextDownFinite(value: number): number { + if (value === 0) return -Number.MIN_VALUE + const view = new DataView(new ArrayBuffer(8)) + view.setFloat64(0, value) + const bits = view.getBigUint64(0) + view.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n) + return view.getFloat64(0) +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28 + return [4, 6, 9, 11].includes(month) ? 30 : 31 +} + +function invalidRange(name: string, detail: string): SessionQueryError { + return new SessionQueryError( + `session ${name} range ${detail}`, + 'SESSION_QUERY_INVALID_FILTER', + ) +} + +function assertNonNegativeSafeInteger(name: string, value: number): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new SessionQueryError( + `${name} must be a non-negative safe integer`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +function assertNonEmptyArray(name: string, values: readonly unknown[]): void { + if (values.length === 0) { + throw new SessionQueryError( + `${name} must contain at least one value when supplied`, + 'SESSION_QUERY_INVALID_FILTER', + ) + } +} + +/** Model schemas and model-owned value normalization shared by tool operations. */ +export const toolInput = { + sessionSearchParameters, + eventSearchParameters, + targetSessionParameter, + buildSessionFilters, + materializeParentSessionIds, + buildEventFilters, + normalizeQuery, + sequenceRange, + assertNonNegativeSafeInteger, +} diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts new file mode 100644 index 0000000000..f169842823 --- /dev/null +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -0,0 +1,281 @@ +/** + * Tool operation orchestration over session-query service capabilities. + * + * @module @deepseek-ai/dsh-tool-session-query/operations + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionQueryError, + type SessionEventSearchPage, + type SessionEventSurface, + type SessionRecord, + type SessionSearchCursor, +} from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { toolInput } from './input.ts' +import { presentation } from './presentation.ts' +import { serviceBoundary } from './service-boundary.ts' +import { workspaceAccess } from './workspace-access.ts' + +type SessionSearchArgs = Parameters[0] + +interface EventSearchArgs { + session_id?: string + query: string + seq_from?: number + seq_to?: number + time_from?: string + time_to?: string + event_types?: string[] + surfaces?: SessionEventSurface[] +} + +interface SessionTargetArgs { + session_id?: string +} + +interface EventTargetArgs extends SessionTargetArgs { + seq: number +} + +interface EventReadArgs extends EventTargetArgs { + before?: number + after?: number +} + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +async function executeSessionSearch( + ctx: Context, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) + + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) +} + +async function executeEventSearch( + ctx: Context, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStart = caller.events.findLast(event => event.type === 'step/start') + if (stepStart === undefined) { + throw new HarnessError( + 'current-session search requires an active step boundary', + 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', + ) + } + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) +} + +async function executeSessionTrace( + ctx: Context, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) + + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break + } + ancestors.push(ancestor) + } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) +} + +async function executeEventTrace( + ctx: Context, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) +} + +async function executeEventRead( + ctx: Context, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = workspaceAccess.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq: args.seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) +} + +async function collectPages( + maxResults: number, + signal: AbortSignal, + request: (cursor?: SessionSearchCursor) => Promise<{ + readonly items: readonly T[] + readonly nextCursor?: SessionSearchCursor + }>, + accept: (item: T) => boolean, +): Promise> { + const items: T[] = [] + const seen = new Set() + let cursor: SessionSearchCursor | undefined + while (true) { + signal.throwIfAborted() + const page = await request(cursor) + signal.throwIfAborted() + for (const item of page.items) { + if (!accept(item)) continue + if (items.length === maxResults) { + return { items, capped: true } + } + items.push(item) + } + if (page.nextCursor === undefined) return { items, capped: false } + if (seen.has(page.nextCursor)) { + throw new SessionQueryError( + 'session-search provider repeated a continuation cursor', + 'SESSION_QUERY_INVALID_CURSOR', + ) + } + seen.add(page.nextCursor) + cursor = page.nextCursor + } +} + +/** Five model-facing session-query operation implementations. */ +export const operations = { + executeSessionSearch, + executeEventSearch, + executeSessionTrace, + executeEventTrace, + executeEventRead, +} diff --git a/packages/session-query/tool-session-query/src/presentation.ts b/packages/session-query/tool-session-query/src/presentation.ts new file mode 100644 index 0000000000..6e99bd22eb --- /dev/null +++ b/packages/session-query/tool-session-query/src/presentation.ts @@ -0,0 +1,255 @@ +/** + * Model text rendering and generic tool-call presentation. + * + * @module @deepseek-ai/dsh-tool-session-query/presentation + */ + +import { + extractSessionEventText, + type SessionEventSearchHit, + type SessionEventTraceObservation, + type SessionEventWindow, + type SessionLineageTrace, + type SessionRecord, + type SessionSearchHit, +} from '@deepseek-ai/dsh-session-query' +import type { + SessionEvent, + SessionId, +} from '@deepseek-ai/dsh-session' +import type { GenericCallView } from '@deepseek-ai/dsh-tools' +import { workspaceAccess } from './workspace-access.ts' + +type TitleView = Awaited> +type CompleteTitleMap = Awaited> +type AuthorizedDescendants = ReturnType + +interface SearchCollection { + readonly items: T[] + readonly capped: boolean +} + +interface SessionSearchCallArgs { + readonly query: string +} + +interface EventSearchCallArgs { + readonly query: string +} + +interface SessionTargetCallArgs { + readonly session_id?: string +} + +interface EventTargetCallArgs extends SessionTargetCallArgs { + readonly seq: number +} + +function formatSessionSearch( + collected: SearchCollection, + titles: CompleteTitleMap, + authorizedParents: ReadonlySet, +): string { + if (collected.items.length === 0) return formatEmptySessionSearch() + const lines = [`Session search results (${collected.items.length}):`] + for (const [index, hit] of collected.items.entries()) { + const parent = hit.header.parentSession === undefined + ? 'root' + : authorizedParents.has(hit.header.parentSession) + ? hit.header.parentSession + : '[outside workspace]' + const availability = [ + hit.live ? 'live' : undefined, + hit.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' + lines.push( + '', + `${index + 1}. Session ${hit.header.id} — ${workspaceAccess.titleText(titles.get(hit.header.id))}`, + ` Created: ${formatTime(hit.header.createdAt)}`, + ` Parent: ${parent}`, + ` Availability: ${availability}`, + ` Best match: seq ${hit.bestMatch.seq} | ${hit.bestMatch.type} | ${hit.bestMatch.surface} | ${formatTime(hit.bestMatch.time)}`, + ` Snippet: ${hit.bestMatch.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatEmptySessionSearch(): string { + return 'No prior session matches found.' +} + +function formatEventSearch( + sessionId: SessionId, + title: TitleView, + collected: SearchCollection, +): string { + const lines = [`Session ${sessionId} — ${workspaceAccess.titleText(title)}`] + if (collected.items.length === 0) { + lines.push('', 'No prior event matches found.') + return lines.join('\n') + } + lines.push('', `Event search results (${collected.items.length}):`) + for (const [index, hit] of collected.items.entries()) { + lines.push( + `${index + 1}. seq ${hit.seq} | ${hit.type} | ${hit.surface} | ${formatTime(hit.time)}`, + ` Snippet: ${hit.snippet}`, + ) + } + if (collected.capped) { + lines.push('', 'Result cap reached. Narrow the query or add filters to find additional matches.') + } + return lines.join('\n') +} + +function formatSessionTrace( + trace: SessionLineageTrace, + ancestors: readonly SessionRecord[], + ancestorBoundary: boolean, + descendants: AuthorizedDescendants, + titles: CompleteTitleMap, +): string { + const lines = [ + `Session ${trace.target.header.id} — ${workspaceAccess.titleText(titles.get(trace.target.header.id))}`, + `Created: ${formatTime(trace.target.header.createdAt)}`, + `Availability: ${availabilityText(trace.target)}`, + '', + 'Ancestors (nearest first):', + ] + if (ancestors.length === 0 && !ancestorBoundary) lines.push('- none (target is a root session)') + for (const record of ancestors) { + lines.push(`- ${record.header.id} — ${workspaceAccess.titleText(titles.get(record.header.id))} | ${formatTime(record.header.createdAt)} | ${availabilityText(record)}`) + } + if (ancestorBoundary) lines.push('- [outside workspace boundary]') + lines.push('', 'Descendants:') + if (descendants.length === 0) lines.push('- none') + else renderDescendants(lines, descendants, titles) + return lines.join('\n') +} + +function renderDescendants( + lines: string[], + nodes: AuthorizedDescendants, + titles: CompleteTitleMap, +): void { + for (const { node, depth } of workspaceAccess.visitDescendants(nodes)) { + const indent = ' '.repeat(depth) + if (node === null) { + lines.push(`${indent}- [outside workspace subtree]`) + continue + } + const id = node.record.header.id + lines.push(`${indent}- ${id} — ${workspaceAccess.titleText(titles.get(id))} | ${formatTime(node.record.header.createdAt)} | ${availabilityText(node.record)}`) + } +} + +function formatEventTrace( + sessionId: SessionId, + title: TitleView, + trace: SessionEventTraceObservation, +): string { + return [ + `Session ${sessionId} — ${workspaceAccess.titleText(title)}`, + `Target: seq ${trace.target.seq} | ${trace.target.type} | ${trace.target.surface} | ${formatTime(trace.target.time)}`, + `Replaced by: ${trace.replacedBy ?? 'none'}`, + `Replacement chain: ${seqList(trace.replacementChain)}`, + `Events replaced by target: ${seqList(trace.replacedEventSeqs)}`, + `Direct provenance sources: ${seqList(trace.sourceEventSeqs)}`, + `Direct derived events: ${seqList(trace.derivedEventSeqs)}`, + ].join('\n') +} + +function formatEventRead( + sessionId: SessionId, + title: TitleView, + window: SessionEventWindow, +): string { + const before = window.events.filter(event => event.seq < window.target.seq) + const after = window.events.filter(event => event.seq > window.target.seq) + const lines = [ + `Session ${sessionId} — ${workspaceAccess.titleText(title)}`, + `Target event seq ${window.target.seq}:`, + '```json', + JSON.stringify(window.target, null, 2), + '```', + ] + if (before.length > 0) { + lines.push('', 'Before:') + for (const event of before) lines.push(formatNeighbor(event)) + } + if (after.length > 0) { + lines.push('', 'After:') + for (const event of after) lines.push(formatNeighbor(event)) + } + return lines.join('\n') +} + +function formatNeighbor(event: SessionEvent): string { + const text = extractSessionEventText(event) + return `- seq ${event.seq} | ${event.type} | ${formatTime(event.time)}` + + (text.length === 0 ? ' | (no semantic text)' : `\n ${text.replaceAll('\n', '\n ')}`) +} + +function availabilityText(record: SessionRecord): string { + return [ + record.live ? 'live' : undefined, + record.persisted ? 'persisted' : undefined, + ].filter((value): value is string => value !== undefined).join(', ') || 'unavailable' +} + +function seqList(values: readonly number[]): string { + return values.length === 0 ? 'none' : values.join(', ') +} + +function formatTime(value: number): string { + return new Date(value).toISOString() +} + +function presentSessionSearchCall(args: SessionSearchCallArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search prior sessions', rawInput: args.query } +} + +function presentEventSearchCall(args: EventSearchCallArgs): GenericCallView { + return { card: 'generic', kind: 'search', title: 'Search session events', rawInput: args.query } +} + +function presentSessionTraceCall(args: SessionTargetCallArgs): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: args.session_id === undefined ? 'Trace current session' : `Trace session ${args.session_id}`, + ...args.session_id === undefined ? {} : { rawInput: args.session_id }, + } +} + +function presentEventTargetCall( + action: string, + args: EventTargetCallArgs, +): GenericCallView { + return { + card: 'generic', + kind: 'read', + title: `${action} ${args.seq}`, + rawInput: { + ...args.session_id === undefined ? {} : { session_id: args.session_id }, + seq: args.seq, + }, + } +} + +/** Text output and call-card presentation for every session-query tool. */ +export const presentation = { + formatSessionSearch, + formatEmptySessionSearch, + formatEventSearch, + formatSessionTrace, + formatEventTrace, + formatEventRead, + presentSessionSearchCall, + presentEventSearchCall, + presentSessionTraceCall, + presentEventTargetCall, +} diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts new file mode 100644 index 0000000000..bf1dbd24f4 --- /dev/null +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -0,0 +1,171 @@ +/** + * Session-query service error containment and model-safe translation. + * + * @module @deepseek-ai/dsh-tool-session-query/service-boundary + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SessionQueryError, + type SessionQueryErrorCode, +} from '@deepseek-ai/dsh-session-query' + +interface ModelSafeServiceFailure { + readonly code: SessionQueryErrorCode | 'SESSION_QUERY_TOOL_FAILED' + readonly message: string +} + +const UNPRINTABLE_SERVICE_ERROR = '[unprintable session query failure]' + +const SAFE_SESSION_QUERY_FAILURES = { + SESSION_QUERY_ABORTED: { + code: 'SESSION_QUERY_ABORTED', + message: 'session query was cancelled', + }, + SESSION_QUERY_EVENT_NOT_FOUND: { + code: 'SESSION_QUERY_EVENT_NOT_FOUND', + message: 'session event was not found', + }, + SESSION_QUERY_INDEX_FAILED: { + code: 'SESSION_QUERY_INDEX_FAILED', + message: 'session search index is unavailable', + }, + SESSION_QUERY_INVALID_CONFIG: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, + SESSION_QUERY_INVALID_CURSOR: { + code: 'SESSION_QUERY_INVALID_CURSOR', + message: 'session search continuation is invalid', + }, + SESSION_QUERY_INVALID_FILTER: { + code: 'SESSION_QUERY_INVALID_FILTER', + message: 'session query filters were rejected', + }, + SESSION_QUERY_INVALID_LIMIT: { + code: 'SESSION_QUERY_INVALID_LIMIT', + message: 'session query result limit was rejected', + }, + SESSION_QUERY_INVALID_QUERY: { + code: 'SESSION_QUERY_INVALID_QUERY', + message: 'session query was rejected', + }, + SESSION_QUERY_INVALID_LINEAGE: { + code: 'SESSION_QUERY_INVALID_LINEAGE', + message: 'session lineage is invalid', + }, + SESSION_QUERY_INVALID_SURFACE: { + code: 'SESSION_QUERY_INVALID_SURFACE', + message: 'session event history is invalid', + }, + SESSION_QUERY_INVALID_WINDOW: { + code: 'SESSION_QUERY_INVALID_WINDOW', + message: 'session event window is invalid', + }, + SESSION_QUERY_PERSISTENCE_FAILED: { + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + message: 'session history storage is unavailable', + }, + SESSION_QUERY_SESSION_NOT_FOUND: { + code: 'SESSION_QUERY_SESSION_NOT_FOUND', + message: 'session was not found', + }, + SESSION_QUERY_STALE_CURSOR: { + code: 'SESSION_QUERY_STALE_CURSOR', + message: 'session history changed while paging; retry the complete search call', + }, + SESSION_QUERY_SOURCE_CONFLICT: { + code: 'SESSION_QUERY_TOOL_FAILED', + message: 'session query operation failed', + }, +} satisfies Record + +function unauthorizedTarget(): HarnessError { + return new HarnessError( + 'session target is outside the caller workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) +} + +async function call( + ctx: Context, + signal: AbortSignal, + operation: string, + invoke: () => Promise, +): Promise { + signal.throwIfAborted() + try { + const value = await invoke() + signal.throwIfAborted() + return value + } catch (error: unknown) { + signal.throwIfAborted() + throw sanitizeError(ctx, operation, error) + } +} + +function sanitizeError( + ctx: Context, + operation: string, + error: unknown, +): HarnessError { + const generic = genericFailure() + const diagnostic = fullError(error) + try { + ctx.logger.warn(`tool-session-query: ${operation} failed: ${diagnostic}`) + if (error instanceof SessionQueryError) { + const code: unknown = error.code + const failure = typeof code === 'string' && Object.hasOwn(SAFE_SESSION_QUERY_FAILURES, code) + ? SAFE_SESSION_QUERY_FAILURES[code as SessionQueryErrorCode] + : undefined + if (failure !== undefined && failure.code !== 'SESSION_QUERY_TOOL_FAILED') { + return new SessionQueryError(failure.message, failure.code) + } + } + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') { + return unauthorizedTarget() + } + } catch { + return generic + } + return generic +} + +function genericFailure(): HarnessError { + return new HarnessError( + 'session query operation failed', + 'SESSION_QUERY_TOOL_FAILED', + ) +} + +function fullError(error: unknown): string { + try { + return renderFullError(error) + } catch { + return UNPRINTABLE_SERVICE_ERROR + } +} + +function renderFullError(error: unknown): string { + if (!(error instanceof Error)) return String(error) + const diagnostics: string[] = [] + const seen = new Set() + let current: unknown = error + while (current instanceof Error && !seen.has(current)) { + seen.add(current) + diagnostics.push(current.stack ?? String(current)) + current = current.cause + } + /* v8 ignore next -- defensive containment for a cyclic Error.cause graph */ + if (current instanceof Error) diagnostics.push('[circular error cause]') + else if (current !== undefined) diagnostics.push(renderFullError(current)) + return diagnostics.join('\nCaused by: ') +} + +/** Model-safe session-query invocation and error translation boundary. */ +export const serviceBoundary = { + unauthorizedTarget, + call, + sanitizeError, +} diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts new file mode 100644 index 0000000000..faba3adf9f --- /dev/null +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -0,0 +1,255 @@ +/** + * Caller identity, workspace authorization, and visible lineage projection. + * + * @module @deepseek-ai/dsh-tool-session-query/workspace-access + */ + +import type { Context } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import { + SessionId, + type SessionEvent, + type SessionHeader, + type SessionId as SessionIdValue, +} from '@deepseek-ai/dsh-session' +import type { + SessionLineageNode, + SessionRecord, +} from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import { serviceBoundary } from './service-boundary.ts' + +interface Caller { + readonly id: SessionIdValue + readonly header: SessionHeader + readonly events: readonly SessionEvent[] +} + +interface TitleView { + readonly text: string + readonly unavailableCode?: string +} + +interface CompleteTitleMap extends ReadonlyMap { + get(id: SessionIdValue): TitleView +} + +interface AuthorizedDescendant { + readonly record: SessionRecord + readonly descendants: Array +} + +interface DescendantProjectionFrame { + readonly node: SessionLineageNode + readonly target: Array + readonly next: DescendantProjectionFrame | undefined +} + +interface DescendantVisit { + readonly node: AuthorizedDescendant | null + readonly depth: number + readonly next: DescendantVisit | undefined +} + +function callerOf(exec: ToolRunContext): Caller { + const agent = exec.agent + if (agent === undefined) { + throw new HarnessError( + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', + ) + } + return { + id: agent.session.id, + header: agent.session.header, + events: agent.session.events, + } +} + +function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { + return args.session_id === undefined ? caller.id : SessionId(args.session_id) +} + +async function authorizeTarget( + ctx: Context, + caller: Caller, + target: SessionIdValue, + signal: AbortSignal, +): Promise { + if (target === caller.id) return + const cwd = caller.header.cwd + if (cwd === undefined) throw serviceBoundary.unauthorizedTarget() + const records = await serviceBoundary.call(ctx, signal, 'target authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: [target] }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + if (records.length !== 1) throw serviceBoundary.unauthorizedTarget() +} + +function recordAuthorized(record: SessionRecord, caller: Caller): boolean { + return headerAuthorized(record.header, caller) +} + +function headerAuthorized(header: SessionHeader, caller: Caller): boolean { + if (header.id === caller.id) return header.cwd === caller.header.cwd + return caller.header.cwd !== undefined && header.cwd === caller.header.cwd +} + +function assertObservedTargetAuthorized( + caller: Caller, + target: SessionIdValue, + observed: SessionHeader, +): void { + if (observed.id !== target || !headerAuthorized(observed, caller)) { + throw serviceBoundary.unauthorizedTarget() + } +} + +async function authorizeSessionIds( + ctx: Context, + caller: Caller, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise> { + const unique = [...new Set(ids)] + const authorized = new Set() + if (unique.includes(caller.id)) authorized.add(caller.id) + const cwd = caller.header.cwd + const other = unique.filter(id => id !== caller.id) + if (cwd === undefined || other.length === 0) return authorized + const records = await serviceBoundary.call(ctx, signal, 'session-id authorization', () => + ctx.sessionQuery.filterSessions([ + { kind: 'id', values: other }, + { kind: 'cwd', values: [cwd] }, + ], signal)) + const requested = new Set(other) + for (const record of records) { + if (requested.has(record.header.id) && recordAuthorized(record, caller)) { + authorized.add(record.header.id) + } + } + return authorized +} + +async function readTitles( + ctx: Context, + caller: Caller, + ids: readonly SessionIdValue[], + signal: AbortSignal, +): Promise { + const result = new Map() + const observations = await serviceBoundary.call(ctx, signal, 'title observation', () => + ctx.sessionQuery.readTitleSnapshots(ids, signal)) + for (const observation of observations) { + if (observation.status === 'rejected') { + result.set(observation.sessionId, unavailableTitle(ctx, observation.reason)) + continue + } + assertObservedTargetAuthorized(caller, observation.sessionId, observation.value.session) + result.set(observation.sessionId, { text: observation.value.title?.title ?? 'untitled' }) + } + return result as CompleteTitleMap +} + +async function readTitle( + ctx: Context, + caller: Caller, + id: SessionIdValue, + signal: AbortSignal, +): Promise { + return (await readTitles(ctx, caller, [id], signal)).get(id) +} + +function unavailableTitle( + ctx: Context, + error: unknown, +): TitleView { + const sanitized = serviceBoundary.sanitizeError(ctx, 'title observation item', error) + if (sanitized.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw sanitized + return { text: 'untitled', unavailableCode: sanitized.code } +} + +function authorizeDescendants( + nodes: readonly SessionLineageNode[], + caller: Caller, +): Array { + const result: Array = [] + let pending: DescendantProjectionFrame | undefined + for (const node of [...nodes].reverse()) { + pending = { node, target: result, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + if (!recordAuthorized(current.node.session, caller)) { + current.target.push(null) + continue + } + const projected: AuthorizedDescendant = { + record: current.node.session, + descendants: [], + } + current.target.push(projected) + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + target: projected.descendants, + next: pending, + } + } + } + return result +} + +function * visitDescendants( + nodes: readonly (AuthorizedDescendant | null)[], +): Generator { + let pending: DescendantVisit | undefined + for (const node of [...nodes].reverse()) { + pending = { node, depth: 0, next: pending } + } + while (pending !== undefined) { + const current = pending + pending = current.next + yield current + if (current.node === null) continue + for (const child of [...current.node.descendants].reverse()) { + pending = { + node: child, + depth: current.depth + 1, + next: pending, + } + } + } +} + +function descendantIds(nodes: readonly (AuthorizedDescendant | null)[]): SessionIdValue[] { + const ids: SessionIdValue[] = [] + for (const { node } of visitDescendants(nodes)) { + if (node !== null) ids.push(node.record.header.id) + } + return ids +} + +function titleText(view: TitleView): string { + return view.unavailableCode === undefined + ? view.text + : `${view.text} (title unavailable: ${view.unavailableCode})` +} + +/** Workspace-scoped caller authorization, title access, and lineage projection. */ +export const workspaceAccess = { + callerOf, + targetId, + authorizeTarget, + recordAuthorized, + assertObservedTargetAuthorized, + authorizeSessionIds, + readTitles, + readTitle, + authorizeDescendants, + visitDescendants, + descendantIds, + titleText, +} From b78daaad8c97484754d5fb0fe706d4cac41c3dfa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 25 Jul 2026 18:14:36 +0800 Subject: [PATCH 19/19] fix(session-query): keep model tools opt-in --- ...model-facing-session-query-tools.i18n.yaml | 4 +- ...-07-24-model-facing-session-query-tools.md | 4 +- ...-24-model-facing-session-query-tools.zh.md | 4 +- docs/tool-catalog.md | 4 +- examples/acp-agent/README.md | 2 +- examples/acp-agent/composition.md | 12 - examples/acp-agent/cordis.yml | 20 - examples/acp-agent/fs.cordis.snapshot.yml | 23 +- examples/acp-agent/fs.cordis.yml | 23 +- .../session-query.cordis.snapshot.yml | 12 + examples/acp-agent/session-query.cordis.yml | 12 + examples/acp-agent/tests/acp.snapshot.ts | 5 +- .../system-prompt.expected.md | 78 -- .../tool-schemas.expected.json | 204 ------ .../both-mode-turn/system-prompt.expected.md | 78 -- .../both-mode-turn/tool-schemas.expected.json | 204 ------ .../code-mode-turn/system-prompt.expected.md | 78 -- .../system-prompt.expected.md | 78 -- .../system-prompt.expected.md | 2 - .../tool-schemas.expected.json | 204 ------ .../lsp-definition/system-prompt.expected.md | 2 - .../lsp-definition/tool-schemas.expected.json | 204 ------ .../pty-tools/system-prompt.expected.md | 2 - .../pty-tools/tool-schemas.expected.json | 204 ------ .../session-query-spill/session.jsonl | 2 +- .../system-prompt.expected.md | 27 + .../tool-schemas.expected.json | 677 ++++++++++++++++++ .../skill-load/system-prompt.expected.md | 2 - .../skill-load/tool-schemas.expected.json | 204 ------ .../text-turn/system-prompt.expected.md | 2 - .../text-turn/tool-schemas.expected.json | 204 ------ .../system-prompt.expected.md | 2 - .../tool-schemas.expected.json | 204 ------ examples/tui-agent/composition.md | 3 - examples/tui-agent/cordis.yml | 5 - packages/examples/acp-demo/README.md | 2 +- .../examples/acp-demo/tests/load-path.e2e.ts | 5 +- packages/examples/tui-demo/README.md | 2 +- packages/host/runtime/README.md | 44 +- packages/host/runtime/package.json | 1 - packages/host/runtime/src/boot.ts | 2 - .../host/runtime/tests/host-runtime.spec.ts | 5 +- packages/host/runtime/tsconfig.json | 3 - .../tool-session-query/README.md | 2 +- pnpm-lock.yaml | 3 - scripts/gen-tool-catalog.ts | 2 +- 46 files changed, 779 insertions(+), 2087 deletions(-) create mode 100644 examples/acp-agent/session-query.cordis.snapshot.yml create mode 100644 examples/acp-agent/session-query.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md create mode 100644 examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 86b4e1deed..bf64ecf4d3 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: bc9143150d1e17eda9eab7f4864ed3a2f4983157 -2026-07-24-model-facing-session-query-tools.zh.md: c8a0c70789f21e4bbca523b6acc81925fb17b604 +2026-07-24-model-facing-session-query-tools.md: f05f9792619155070ec1c5c721702d7ed94442ce +2026-07-24-model-facing-session-query-tools.zh.md: 2570dda4b3ff22ff26e12e2f9d1418d76f079f5c diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index bc9143150d..f05f979261 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -36,7 +36,7 @@ Session-level results include the latest folded title when available. Each tool ## Host composition -The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.sessionQuery`. TUI and Web use their existing timeout and spill policies. ACP mounts the same timeout policy and private local spill backend with the shared 50,000-byte inline threshold, so the five tools have one model-facing contract across hosts. Web also mounts the SQLite query backend at its persistence root; generic tool presentation requires no session-query-specific client plugin. +The consumer is an opt-in plugin. The shipped ACP, TUI, and Web compositions mount `ctx.sessionQuery` for their non-model consumers but do not mount `@deepseek-ai/dsh-tool-session-query`, so their default model requests gain no query prompt or schemas. A composition that opts in also chooses whether to mount the generic timeout and spill policies; the dedicated ACP snapshot fixture mounts both and uses private local spill storage. Generic tool presentation requires no session-query-specific client plugin. ## Alternatives considered @@ -48,7 +48,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, parent-filter preauthorization and oracle resistance, changed-observation rejection, service-diagnostic redaction for ordinary and adversarial unknown values, best-effort cyclic-cause logging, logger-failure containment, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Default-host tests and assembled request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available. A package-owned Loader smoke and dedicated keyless ACP snapshot explicitly mount the consumer with timeout and spill support, pinning its prompt guidance, schemas, and path-independent exact event-read retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index c8a0c70789..2570dda4b3 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -36,7 +36,7 @@ Status: implemented ## 宿主组合 -发布的 ACP、TUI 与 Web 组合都在 `ctx.sessionQuery` 旁挂载该消费者。TUI 与 Web 使用已有的超时与 spill 策略。ACP 挂载同一超时策略与私有本地 spill 后端,并采用共享的 50,000 字节行内阈值,因此五个工具在各宿主中具有同一面向模型的契约。Web 还在其持久化根目录挂载 SQLite 查询后端;通用工具表现无需会话查询专用客户端插件。 +该消费方是一个需显式启用的插件。发布的 ACP、TUI 与 Web 组合为其非模型消费方挂载 `ctx.sessionQuery`,但不挂载 `@deepseek-ai/dsh-tool-session-query`,因此其默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。 ## 考虑过的替代方案 @@ -48,7 +48,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。默认宿主测试与组装后的请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。 ## 后果 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 06a0ec989c..3cdc822a7a 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -27,7 +27,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-local`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. | | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy. | +| `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | | `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | @@ -1010,7 +1010,7 @@ Read the authorized session lineage around one session, including complete visib Source: [`packages/session-query/tool-session-query/src/index.ts`](../packages/session-query/tool-session-query/src/index.ts) -The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy. +The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. ## `@deepseek-ai/dsh-tool-subagent` diff --git a/examples/acp-agent/README.md b/examples/acp-agent/README.md index 52cc51db15..892a32cdc7 100644 --- a/examples/acp-agent/README.md +++ b/examples/acp-agent/README.md @@ -7,7 +7,7 @@ pnpm run demo:acp # needs DEEPSEEK_API_KEY (repo-root .env or env) pnpm run demo:code-mode acp # same protocol with the Code Mode tool transport ``` -The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, workspace-authorized session-query tools, generic timeout and local spill policies, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`fs.cordis.yml`](fs.cordis.yml) redirects spill storage and lowers the inline threshold for dedicated filesystem scenarios; [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. +The leaf loads the ACP app, DeepSeek adapter, sandboxed bash and filesystem stacks, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, and repeat guard. The app creates one fresh agent per `session/new`, persists sessions to JSONL, and keeps stdout protocol-pure. [`session-query.cordis.yml`](session-query.cordis.yml) explicitly opts into the workspace-authorized query tools and generic timeout/spill policies for their dedicated snapshot; [`fs.cordis.yml`](fs.cordis.yml) adds spill storage for filesystem scenarios, while [`code-mode.cordis.yml`](code-mode.cordis.yml) adds `run_code` and its generated TypeScript SDK. ## Protocol channel diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index ba9ece13b5..8d112f2910 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -27,14 +27,6 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_acp_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_acp_tool_session_query - plugin_acp_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_acp_timeout_policy - plugin_acp_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_acp_spill_local - plugin_acp_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_acp_spill_policy plugin_acp_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_acp_token_meter plugin_acp_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] @@ -79,10 +71,6 @@ flowchart LR | `bash` | `@deepseek-ai/dsh-bash-sandbox` | | `approval` | `@deepseek-ai/dsh-user-approval` | | `acp-agent` | `@deepseek-ai/dsh-acp-demo` | -| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | -| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | -| `spill-local` | `@deepseek-ai/dsh-spill-local` | -| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index 484809db04..fa7f6ad4ef 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -63,26 +63,6 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# The automation app opens ctx.sessionQuery before its ACP transport; this leaf -# owns the workspace-authorized model-facing consumer. -- id: tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - -# Enforce declared search deadlines and spill oversized plain-text tool output -# without introducing a session-query-specific truncation path. -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - config: - root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 - # Replay-aware request pressure; the routed adapter supplies model capacity. - id: token-meter name: '@deepseek-ai/dsh-token-meter' diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index 29cef3fe82..0417074edd 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -1,6 +1,7 @@ -# Keyless filesystem snapshots patch the base spill stack and apply the replay -# overlay directly. The sandboxed filesystem stack already lives in the base -# cordis.yml. This file also re-pins the acp-agent model to `deepseek-v4-flash`: `cordis.yml` ships +# Keyless filesystem snapshots apply the spill and replay overlays directly +# because include patches cannot target entries behind a nested include. The +# sandboxed filesystem stack already lives in the base cordis.yml. This file also +# re-pins the acp-agent model to `deepseek-v4-flash`: `cordis.yml` ships # `deepseek-v4-pro`, but the recorded corpus was captured on flash, and a config # patch replaces the whole app config, so the base fields are restated verbatim. - id: base @@ -24,15 +25,15 @@ You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. Verify your work by running the code or tests. Keep answers brief and factual. - - id: spill-local - name: '@deepseek-ai/dsh-spill-local' - config: - root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' - - id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 800 - insert: + - id: spill-local + name: '@deepseek-ai/dsh-spill-local' + config: + root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' + - id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 800 - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' config: diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml index 4528ccf7c0..0d667255c8 100644 --- a/examples/acp-agent/fs.cordis.yml +++ b/examples/acp-agent/fs.cordis.yml @@ -1,16 +1,17 @@ -# Filesystem-scenario overlay: the sandboxed filesystem and generic spill stacks -# already live in the base cordis.yml, so this overlay only redirects spill -# storage and lowers the inline threshold for dedicated scenarios. +# Filesystem-scenario overlay: the sandboxed filesystem stack already lives in +# the base cordis.yml, so this overlay adds only the local tool-result spill +# storage those scenarios exercise. - id: base name: '@cordisjs/plugin-include' config: path: ./cordis.yml patches: - - id: spill-local - name: '@deepseek-ai/dsh-spill-local' - config: - root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' - - id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: !!js process.env.DSH_SNAPSHOT && 800 || 50000 + - insert: + - id: spill-local + name: '@deepseek-ai/dsh-spill-local' + config: + root: !!js process.env.DSH_SNAPSHOT_SPILL_ROOT ?? './.spill' + - id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: !!js process.env.DSH_SNAPSHOT && 800 || 50000 diff --git a/examples/acp-agent/session-query.cordis.snapshot.yml b/examples/acp-agent/session-query.cordis.snapshot.yml new file mode 100644 index 0000000000..1edadf8374 --- /dev/null +++ b/examples/acp-agent/session-query.cordis.snapshot.yml @@ -0,0 +1,12 @@ +# Keyless counterpart to session-query.cordis.yml: the nested snapshot overlay +# supplies replay plus deterministic private spill storage and its byte limit. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./fs.cordis.snapshot.yml + patches: + - insert: + - id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' diff --git a/examples/acp-agent/session-query.cordis.yml b/examples/acp-agent/session-query.cordis.yml new file mode 100644 index 0000000000..e5e45025df --- /dev/null +++ b/examples/acp-agent/session-query.cordis.yml @@ -0,0 +1,12 @@ +# Explicit session-query tool opt-in for the dedicated spill scenario. The +# nested filesystem overlay supplies private spill storage and its byte limit. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./fs.cordis.yml + patches: + - insert: + - id: tool-session-query + name: '@deepseek-ai/dsh-tool-session-query' + - id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index f7fa1a2a47..8e74711a57 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -34,6 +34,7 @@ const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cordis.yml', import.meta.url)) const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) +const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const PACKED_CHUNKS_CONFIG = fileURLToPath(new URL('../packed-chunks.cordis.yml', import.meta.url)) @@ -92,7 +93,9 @@ const SCENARIOS: Scenario[] = [ name: 'session-query-spill', hasModelTurn: true, recorded: false, - configPath: FS_CONFIG, + pinsHeader: true, + headerClass: 'session-query', + configPath: SESSION_QUERY_CONFIG, posixOnly: true, }, { diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index d45a386c99..fde52770d5 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -115,77 +113,6 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ - session_event_read: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - /** Number of preceding raw events to summarize. Omit for none. */ - before?: number; - /** Number of following raw events to summarize. Omit for none. */ - after?: number; - } & Record; - /** Search prior events in one authorized session; the current session excludes the step performing this call. */ - session_event_search: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Literal full-text query over the target session. */ - query: string; - /** Inclusive event sequence lower bound. */ - seq_from?: number; - /** Inclusive event sequence upper bound. */ - seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read every direct replacement and provenance relationship for one event in an authorized session. */ - session_event_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - } & Record; - /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ - session_search: { - /** Literal full-text query over prior session history. */ - query: string; - /** Optional session ids to include. */ - session_ids?: string[]; - /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ - created_at_from?: string; - /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ - created_at_to?: string; - /** Optional direct parent session ids. */ - parent_session_ids?: string[]; - /** Include sessions with no parent in the parent filter. */ - include_root_sessions?: boolean; - /** Require at least one selected source availability. */ - availability?: ("live" | "persisted")[]; - /** Inclusive event sequence lower bound. */ - event_seq_from?: number; - /** Inclusive event sequence upper bound. */ - event_seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - event_time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - event_time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - event_surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ - session_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -385,11 +312,6 @@ interface ToolOutputMap { }[]; totalLines: number; }; - session_event_read: string; - session_event_search: string; - session_event_trace: string; - session_search: string; - session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index bb239d8c2d..73b9176478 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -232,210 +232,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md index b66bb0de0c..3817b0bc8a 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -98,77 +96,6 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ - session_event_read: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - /** Number of preceding raw events to summarize. Omit for none. */ - before?: number; - /** Number of following raw events to summarize. Omit for none. */ - after?: number; - } & Record; - /** Search prior events in one authorized session; the current session excludes the step performing this call. */ - session_event_search: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Literal full-text query over the target session. */ - query: string; - /** Inclusive event sequence lower bound. */ - seq_from?: number; - /** Inclusive event sequence upper bound. */ - seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read every direct replacement and provenance relationship for one event in an authorized session. */ - session_event_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - } & Record; - /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ - session_search: { - /** Literal full-text query over prior session history. */ - query: string; - /** Optional session ids to include. */ - session_ids?: string[]; - /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ - created_at_from?: string; - /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ - created_at_to?: string; - /** Optional direct parent session ids. */ - parent_session_ids?: string[]; - /** Include sessions with no parent in the parent filter. */ - include_root_sessions?: boolean; - /** Require at least one selected source availability. */ - availability?: ("live" | "persisted")[]; - /** Inclusive event sequence lower bound. */ - event_seq_from?: number; - /** Inclusive event sequence upper bound. */ - event_seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - event_time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - event_time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - event_surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ - session_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -356,11 +283,6 @@ interface ToolOutputMap { }[]; totalLines: number; }; - session_event_read: string; - session_event_search: string; - session_event_trace: string; - session_search: string; - session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json index ac3323d626..0fc8107917 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/tool-schemas.expected.json @@ -175,210 +175,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md index b66bb0de0c..3817b0bc8a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -98,77 +96,6 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ - session_event_read: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - /** Number of preceding raw events to summarize. Omit for none. */ - before?: number; - /** Number of following raw events to summarize. Omit for none. */ - after?: number; - } & Record; - /** Search prior events in one authorized session; the current session excludes the step performing this call. */ - session_event_search: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Literal full-text query over the target session. */ - query: string; - /** Inclusive event sequence lower bound. */ - seq_from?: number; - /** Inclusive event sequence upper bound. */ - seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read every direct replacement and provenance relationship for one event in an authorized session. */ - session_event_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - } & Record; - /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ - session_search: { - /** Literal full-text query over prior session history. */ - query: string; - /** Optional session ids to include. */ - session_ids?: string[]; - /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ - created_at_from?: string; - /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ - created_at_to?: string; - /** Optional direct parent session ids. */ - parent_session_ids?: string[]; - /** Include sessions with no parent in the parent filter. */ - include_root_sessions?: boolean; - /** Require at least one selected source availability. */ - availability?: ("live" | "persisted")[]; - /** Inclusive event sequence lower bound. */ - event_seq_from?: number; - /** Inclusive event sequence upper bound. */ - event_seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - event_time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - event_time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - event_surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ - session_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -356,11 +283,6 @@ interface ToolOutputMap { }[]; totalLines: number; }; - session_event_read: string; - session_event_search: string; - session_event_trace: string; - session_search: string; - session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md index b66bb0de0c..3817b0bc8a 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). @@ -98,77 +96,6 @@ interface ToolArgsMap { /** Maximum number of lines to return. Defaults to 2000. */ limit?: number; } & Record; - /** Read one full unabridged event and optional neighboring raw-event summaries from an authorized session. */ - session_event_read: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - /** Number of preceding raw events to summarize. Omit for none. */ - before?: number; - /** Number of following raw events to summarize. Omit for none. */ - after?: number; - } & Record; - /** Search prior events in one authorized session; the current session excludes the step performing this call. */ - session_event_search: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Literal full-text query over the target session. */ - query: string; - /** Inclusive event sequence lower bound. */ - seq_from?: number; - /** Inclusive event sequence upper bound. */ - seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read every direct replacement and provenance relationship for one event in an authorized session. */ - session_event_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - /** Target event sequence number. */ - seq: number; - } & Record; - /** Search prior sessions in the caller workspace and return the strongest matching event from each session. */ - session_search: { - /** Literal full-text query over prior session history. */ - query: string; - /** Optional session ids to include. */ - session_ids?: string[]; - /** Inclusive timezone-qualified ISO 8601 creation-time lower bound. */ - created_at_from?: string; - /** Inclusive timezone-qualified ISO 8601 creation-time upper bound. */ - created_at_to?: string; - /** Optional direct parent session ids. */ - parent_session_ids?: string[]; - /** Include sessions with no parent in the parent filter. */ - include_root_sessions?: boolean; - /** Require at least one selected source availability. */ - availability?: ("live" | "persisted")[]; - /** Inclusive event sequence lower bound. */ - event_seq_from?: number; - /** Inclusive event sequence upper bound. */ - event_seq_to?: number; - /** Inclusive timezone-qualified ISO 8601 event-time lower bound. */ - event_time_from?: string; - /** Inclusive timezone-qualified ISO 8601 event-time upper bound. */ - event_time_to?: string; - /** Event types to include. */ - event_types?: string[]; - /** Event surfaces to include. */ - event_surfaces?: ("current" | "shadowed" | "log-only")[]; - } & Record; - /** Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships. */ - session_trace: { - /** Target session id. Omit for the current session. */ - session_id?: string; - } & Record; /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ skill: { /** The exact skill name from the available skills list. */ @@ -356,11 +283,6 @@ interface ToolOutputMap { }[]; totalLines: number; }; - session_event_read: string; - session_event_search: string; - session_event_trace: string; - session_search: string; - session_trace: string; skill: { name: string; provider: string; diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md index 362d0a6355..e3437ad61a 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/escalation-approved/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json index dde0ba0d7a..d4973bfea4 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/escalation-approved/tool-schemas.expected.json @@ -159,210 +159,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md index cb50752e91..7bde8fe289 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/lsp-definition/system-prompt.expected.md @@ -17,8 +17,6 @@ Track every background task id you start. You are notified in-session when a tas Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json index 3d28b1dfb8..5d27e93da3 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/lsp-definition/tool-schemas.expected.json @@ -196,210 +196,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md index ccce83d9b7..df065a83cb 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/pty-tools/system-prompt.expected.md @@ -17,8 +17,6 @@ Use a terminal session only when work needs persistent terminal state or interac Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json index d29602b97d..e9f7a2ea63 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/pty-tools/tool-schemas.expected.json @@ -159,210 +159,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl index 6d8a294c1e..26fd2bf5c1 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl @@ -11,7 +11,7 @@ {"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} {"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","name":"session_event_read","arguments":"{\"seq\":4}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} +{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"callId":"call_session_query_spill","content":[{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": 1784876318672,\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 36006 bytes. Full formatted result stored at: /tmp/dsh-acp-snap-035d1d054/session-ac29d2afe494/505bce11df84-session_event_read.txt. Use read with offset/limit, or grep this path to search within it.)"}],"isError":false},"sourceEventSeqs":[11],"surfaceOp":"append"} {"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} {"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md new file mode 100644 index 0000000000..68bdd841c7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/system-prompt.expected.md @@ -0,0 +1,27 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). + + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json new file mode 100644 index 0000000000..dde0ba0d7a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/session-query-spill/tool-schemas.expected.json @@ -0,0 +1,677 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "session_event_read", + "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + }, + "before": { + "type": "integer", + "description": "Number of preceding raw events to summarize. Omit for none." + }, + "after": { + "type": "integer", + "description": "Number of following raw events to summarize. Omit for none." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_event_search", + "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "query": { + "type": "string", + "description": "Literal full-text query over the target session." + }, + "seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_event_trace", + "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + }, + "seq": { + "type": "integer", + "description": "Target event sequence number." + } + }, + "required": [ + "seq" + ] + } + }, + { + "name": "session_search", + "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Literal full-text query over prior session history." + }, + "session_ids": { + "type": "array", + "description": "Optional session ids to include.", + "items": { + "type": "string" + } + }, + "created_at_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." + }, + "created_at_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." + }, + "parent_session_ids": { + "type": "array", + "description": "Optional direct parent session ids.", + "items": { + "type": "string" + } + }, + "include_root_sessions": { + "type": "boolean", + "description": "Include sessions with no parent in the parent filter." + }, + "availability": { + "type": "array", + "description": "Require at least one selected source availability.", + "items": { + "type": "string", + "enum": [ + "live", + "persisted" + ] + } + }, + "event_seq_from": { + "type": "integer", + "description": "Inclusive event sequence lower bound." + }, + "event_seq_to": { + "type": "integer", + "description": "Inclusive event sequence upper bound." + }, + "event_time_from": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." + }, + "event_time_to": { + "type": "string", + "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." + }, + "event_types": { + "type": "array", + "description": "Event types to include.", + "items": { + "type": "string" + } + }, + "event_surfaces": { + "type": "array", + "description": "Event surfaces to include.", + "items": { + "type": "string", + "enum": [ + "current", + "shadowed", + "log-only" + ] + } + } + }, + "required": [ + "query" + ] + } + }, + { + "name": "session_trace", + "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", + "parameters": { + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Target session id. Omit for the current session." + } + } + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to return a task id; collect with `task_output` and stop with `task_kill`.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background task and return its id; collect with task_output or stop with task_kill." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md index 68bdd841c7..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/skill-load/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json index dde0ba0d7a..d4973bfea4 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/skill-load/tool-schemas.expected.json @@ -159,210 +159,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md index 68bdd841c7..17e6773a03 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/text-turn/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json index dde0ba0d7a..d4973bfea4 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/text-turn/tool-schemas.expected.json @@ -159,210 +159,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md index 45c9e0970c..6cd8d5725f 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/workspace-context/system-prompt.expected.md @@ -15,8 +15,6 @@ Check the [exit code: N] marker on every bash result; investigate failures befor Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. - Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`). diff --git a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json index dde0ba0d7a..d4973bfea4 100644 --- a/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/workspace-context/tool-schemas.expected.json @@ -159,210 +159,6 @@ ] } }, - { - "name": "session_event_read", - "description": "Read one full unabridged event and optional neighboring raw-event summaries from an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - }, - "before": { - "type": "integer", - "description": "Number of preceding raw events to summarize. Omit for none." - }, - "after": { - "type": "integer", - "description": "Number of following raw events to summarize. Omit for none." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_event_search", - "description": "Search prior events in one authorized session; the current session excludes the step performing this call.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "query": { - "type": "string", - "description": "Literal full-text query over the target session." - }, - "seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_event_trace", - "description": "Read every direct replacement and provenance relationship for one event in an authorized session.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - }, - "seq": { - "type": "integer", - "description": "Target event sequence number." - } - }, - "required": [ - "seq" - ] - } - }, - { - "name": "session_search", - "description": "Search prior sessions in the caller workspace and return the strongest matching event from each session.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Literal full-text query over prior session history." - }, - "session_ids": { - "type": "array", - "description": "Optional session ids to include.", - "items": { - "type": "string" - } - }, - "created_at_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time lower bound." - }, - "created_at_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 creation-time upper bound." - }, - "parent_session_ids": { - "type": "array", - "description": "Optional direct parent session ids.", - "items": { - "type": "string" - } - }, - "include_root_sessions": { - "type": "boolean", - "description": "Include sessions with no parent in the parent filter." - }, - "availability": { - "type": "array", - "description": "Require at least one selected source availability.", - "items": { - "type": "string", - "enum": [ - "live", - "persisted" - ] - } - }, - "event_seq_from": { - "type": "integer", - "description": "Inclusive event sequence lower bound." - }, - "event_seq_to": { - "type": "integer", - "description": "Inclusive event sequence upper bound." - }, - "event_time_from": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time lower bound." - }, - "event_time_to": { - "type": "string", - "description": "Inclusive timezone-qualified ISO 8601 event-time upper bound." - }, - "event_types": { - "type": "array", - "description": "Event types to include.", - "items": { - "type": "string" - } - }, - "event_surfaces": { - "type": "array", - "description": "Event surfaces to include.", - "items": { - "type": "string", - "enum": [ - "current", - "shadowed", - "log-only" - ] - } - } - }, - "required": [ - "query" - ] - } - }, - { - "name": "session_trace", - "description": "Read the authorized session lineage around one session, including complete visible ancestor and descendant relationships.", - "parameters": { - "type": "object", - "properties": { - "session_id": { - "type": "string", - "description": "Target session id. Omit for the current session." - } - } - } - }, { "name": "skill", "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 557923b733..fd6d163952 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -23,8 +23,6 @@ flowchart LR bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_tui_tool_session_query["tool-session-query
@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_tui_tool_session_query plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] @@ -73,7 +71,6 @@ flowchart LR | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | | `bash` | `@deepseek-ai/dsh-bash-local` | | `tui-agent` | `@deepseek-ai/dsh-tui-demo` | -| `tool-session-query` | `@deepseek-ai/dsh-tool-session-query` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | | `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 4fccfff1c7..3070fcdc01 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -52,11 +52,6 @@ Verify your work by running the code or tests. Keep answers brief and factual. -# The app above owns ctx.sessionQuery; expose its workspace-authorized -# prior-session search and exact trace/read operations to the model. -- id: tool-session-query - name: '@deepseek-ai/dsh-tool-session-query' - # Model-made session titles on the first-message cadence: replaces the spine's # deterministic fallback title with a short model summary. The TUI renders the # logged `session/title` as the banner subtitle and the terminal window title. diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index e215a80939..9666e4444f 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -36,7 +36,7 @@ The app does not install commands, user interaction, session navigation, configu | `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. | | `llmRetry` | owner defaults | Bounded transient model-request retry policy. | -The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, a derived session-query index, generic timeout and spill policies, and model-facing tools. Snapshot overlays replace only nondeterministic providers or policy values. +The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. The app supplies the derived session-query index, while the model-facing query consumer remains an explicit leaf opt-in. Snapshot overlays replace only nondeterministic providers or policy values. ## Bin diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 9f61e28314..2c507a0bad 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -28,9 +28,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) // Repo root is four levels up from packages/examples/acp-demo/tests. const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) -// A minimal leaf that loads this app + the two backends and the shipped -// session-query consumer/policies — the same shape as examples/acp-agent/cordis.yml, -// inlined so the package test owns its fixture. +// A minimal opt-in leaf that loads this app + the two backends and the optional +// session-query consumer/policies, inlined so the package test owns its fixture. const CORDIS_YML = ` - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 9e2a50f1b5..e3321bd97f 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and | `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | | `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | | `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; the default leaf adds the model-facing query tools | +| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in | | `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | | `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | | `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 221f0f46c0..14ce60e1c2 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-host-runtime -Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, a derived SQLite FTS session-query index, immediate fallback titles, optional first-message model summaries, system prompt, tool and agent registries, agent loop, five workspace-authorized model-facing session-query tools, workspace instructions, local bash, the generic tool-timeout and 50,000-byte spill policies, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition). +Host runtime assembly for `dsh`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, a derived SQLite FTS session-query index, immediate fallback titles, optional first-message model summaries, system prompt, tool and agent registries, agent loop, workspace instructions, local bash, the generic tool-timeout and 50,000-byte spill policies, and the provider-neutral user-interaction service), and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }` (its `api` comes from [`dsh-host-apiproxy`](../apiproxy/README.md)'s `createApiProxy` over that composition). Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it. @@ -22,53 +22,19 @@ Unary methods take the narrow `RpcRequest

` and echo `request.rpcId`; a prompt ## Model Experience -### Prior-history system prompt +### Optional session-query consumer #### What the model sees -Every main host agent receives the fixed prior-history guidance below because `bootHost` always mounts the session-query tool plugin. - -##### Prior-history guidance - -```markdown -Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data. -``` +The derived `ctx.sessionQuery` index is not model-facing. `bootHost` intentionally leaves the optional [`dsh-tool-session-query`](../../session-query/tool-session-query/README.md) consumer unmounted, so main host agents receive neither its prior-history prompt section nor its five schemas by default. #### Token effect -One fixed concise section is present on every request; `workspaceContext: false` does not remove it. +The index adds no prompt or schema tokens. A custom composition that mounts the consumer owns its added prompt, schemas, calls, and results. #### KV Cache effect -The repeated prefix is stable while the fixed host assembly and guidance text are unchanged. Provider cache availability and eviction remain outside the host contract. - -### Session-query tool schemas - -#### What the model sees - -The fixed assembly mounts the generated [`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query). The schemas expose no workspace path, provider cursor, output page, model-controlled result limit, or timeout argument. - -#### Token effect - -Five fixed read-only schemas are present on every main-agent request; their cost changes only if the host assembly or an agent-scoped visibility policy changes. - -#### KV Cache effect - -The schema prefix is stable while visibility, definitions, and order are unchanged. The host makes no claim that a provider will cache or retain that prefix. - -### Session-query execution and results - -#### What the model sees - -Cross-session results require exact equality with the calling session's workspace, while a caller without a workspace can target only itself. `session_search` excludes the calling session, and `session_event_search` on the current session excludes the step performing the call. Both searches are cursor-free, collect at most 100 authorized results, and carry a cooperative 30-second deadline; the three trace/read tools carry caller cancellation but declare no host deadline. Results are plain text. When a final result exceeds 50,000 UTF-8 bytes, the generic spill policy attempts to retain the complete formatted text in a private session-scoped file and replace it with a bounded preview, locator, and retrieval hint; a spill failure leaves the original result visible. - -#### Token effect - -Call arguments and data-dependent results remain in history until compaction. Search result count is bounded; after a successful spill, only the bounded preview and retrieval notice are resent, while the complete text remains outside model context. - -#### KV Cache effect - -Calls and results append after the reusable request prefix. Compaction may replace earlier history; timeout or spill outcomes change only the appended result text. +The index alone does not change the reusable model-request prefix; mounting the optional consumer would add its stable prompt and schema prefix. ### Workspace instructions diff --git a/packages/host/runtime/package.json b/packages/host/runtime/package.json index cb31e87fe9..b43cb9c468 100644 --- a/packages/host/runtime/package.json +++ b/packages/host/runtime/package.json @@ -58,7 +58,6 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", - "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index 353f74d0b6..4e2976ccc4 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -20,7 +20,6 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' -import * as toolSessionQuery from '@deepseek-ai/dsh-tool-session-query' import LocalBashExecutor from '@deepseek-ai/dsh-bash-local' import * as toolBash from '@deepseek-ai/dsh-tool-bash' import * as toolTodo from '@deepseek-ai/dsh-tool-todo' @@ -135,7 +134,6 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(LlmDeepSeek, {}) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) await ctx.plugin(SessionQuerySqlite, { path: join(options.persistenceRoot, 'session-query.db') }) - await ctx.plugin(toolSessionQuery, {}) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + // the agent-spine bundle) so web sessions get the same coding-agent tool diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 9912ec0785..22f64fb198 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -141,21 +141,20 @@ describe('bootHost / startHost', () => { await handle.dispose() }) - it('assembles workspace-authorized session query tools over the derived SQLite index', async () => { + it('assembles the derived SQLite query index without model-facing query tools', async () => { const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-boot-session-query-')) const handle = await bootHost({ persistenceRoot, workspaceContext: false, }) expect(handle.ctx.get('sessionQuery')).toBeDefined() - expect(handle.ctx.tools.schemas().map(schema => schema.name)).toEqual(expect.arrayContaining([ + expect(handle.ctx.tools.schemas().map(schema => schema.name)).toEqual(expect.not.arrayContaining([ 'session_search', 'session_event_search', 'session_trace', 'session_event_trace', 'session_event_read', ])) - expect(handle.ctx.tools.get('session_search')?.timeoutMs).toBe(30_000) await handle.dispose() }) diff --git a/packages/host/runtime/tsconfig.json b/packages/host/runtime/tsconfig.json index b0cef9eb16..55f517f778 100644 --- a/packages/host/runtime/tsconfig.json +++ b/packages/host/runtime/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../session-query/session-query-sqlite" }, - { - "path": "../../session-query/tool-session-query" - }, { "path": "../../bash/bash-local" }, diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index 504405d698..ef957765c4 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-tool-session-query -Workspace-authorized model tools over `ctx.sessionQuery`. The package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`. +Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default. ## Configuration diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 400ae6df7e..de8e114782 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2346,9 +2346,6 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:^ version: link:../../fs/tool-fs-search - '@deepseek-ai/dsh-tool-session-query': - specifier: workspace:^ - version: link:../../session-query/tool-session-query '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../skill/tool-skill diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 6c81aee2c6..3bbfd5b1ea 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -331,7 +331,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSessionQuery) }, note: - 'The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. Default ACP, TUI, and Web compositions enforce the declared search timeout and apply the generic tool-result spill policy.', + 'The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies.', }, { pkg: '@deepseek-ai/dsh-tool-subagent',