From b92235c490e4d831a27fc2775048b9cc5434ba3e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 14:26:25 +0800 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 800bafda3b08cfe0e48b58f7ff1a5478f9b4b2ba Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 19:43:59 +0800 Subject: [PATCH 14/31] refactor(cli): parse dsh argv through one Commander adapter Replace the dsh CLI's three hand-rolled parsing idioms (raw argv[0]/includes dispatch in bin.ts, per-mode node:util parseArgs in headless.ts/web.ts, and the bespoke parseResumeArg scanner in dsh-app-boot) with a single Commander adapter in apps/cli/src/args.ts. parseDshArgs resolves argv into a discriminated DshInvocation union; bin.ts switches on the mode and dynamic-imports the chosen module, which now consumes already-parsed values. - web is a real subcommand; --host uses choices and --port an argParser range check, moving validation into the parser. - --resume rejects empty and repeated forms; --prompt rejects empty; a config positional after --prompt and a root flag placed before web fail loud. - adds --help/--version; removes parseResumeArg from dsh-app-boot. - new apps/cli/tests/args.spec.ts (apps/*/tests added to vitest include, apps/cli/tests to tsconfig.host.json); the tui-agent keyless PTY smoke covers bin.ts dispatch end to end unchanged. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 6 + ...26-07-24-dsh-commander-argument-adapter.md | 37 ++++ ...07-24-dsh-commander-argument-adapter.zh.md | 37 ++++ apps/cli/README.md | 2 + apps/cli/package.json | 3 +- apps/cli/src/args.ts | 183 ++++++++++++++++++ apps/cli/src/bin.ts | 59 ++++-- apps/cli/src/headless.ts | 19 +- apps/cli/src/tui.ts | 13 +- apps/cli/src/web.ts | 34 +--- apps/cli/tests/args.spec.ts | 120 ++++++++++++ packages/ui/app-boot/README.md | 1 - packages/ui/app-boot/src/index.ts | 44 ----- packages/ui/app-boot/tests/app-boot.spec.ts | 27 +-- pnpm-lock.yaml | 3 + tsconfig.host.json | 1 + vitest.config.ts | 1 + 17 files changed, 460 insertions(+), 130 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md create mode 100644 .agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md create mode 100644 apps/cli/src/args.ts create mode 100644 apps/cli/tests/args.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml new file mode 100644 index 0000000000..5dd6055ffe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: dc2830273b245d370feba0df6ed030045b8444ff +2026-07-24-dsh-commander-argument-adapter.zh.md: ea37a1260ebf81e787f02301bc6fc3c9438c4f75 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md new file mode 100644 index 0000000000..dc2830273b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -0,0 +1,37 @@ +# Agent Note: Parse `dsh` argv through one Commander adapter + +Status: implemented + +English | [中文](2026-07-24-dsh-commander-argument-adapter.zh.md) + +## Problem + +The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that did not compose and gave no `--help`/`--version`. `bin.ts` dispatched by raw inspection — `argv[0] === 'web'`, then `argv.includes('-p') || argv.includes('--prompt')`, else TUI — which is positional-blind: a prompt flag or a config path in the wrong position could misroute the mode, and `argv.includes('-p')` could not tell a real flag from an incidental token. `headless.ts` and `web.ts` each ran their own `node:util` `parseArgs` with inline host/port validation, and `dsh-app-boot` carried `parseResumeArg`, a ~30-line bespoke scanner reimplementing flag/`=`-form/value/repeat handling for `--resume`. Usage was a single hardcoded `usage: dsh -p "task"` line; there was no version flag and no rendered help. + +## Decision + +Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. + +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port)` — none re-reads argv. `web` is a real `program.command('web')` subcommand; `--host` is a Commander `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser. `--resume` uses an `argParser` that rejects both an empty id (`--resume=`) and a repeated flag (`--resume a --resume b`), and `--prompt` rejects an empty task, preserving the old "never silently start fresh" invariant (the deleted `parseResumeArg` failed loud on the same cases). The program sets `enablePositionalOptions()`, and the `web` action rejects a root `--prompt`/`--resume` placed before it, so a misplaced flag (`dsh web -p x`, `dsh -p x web`) fails loud instead of silently serving with defaults. `--version` reads this app's `package.json`. + +`parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. + +## Package topology + +The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin. + +## Alternatives considered + +**Keep `node:util` `parseArgs` and only unify the dispatch** — rejected: `parseArgs` has no subcommand model, no rendered help, and no version flag, so `web` routing and `--help`/`--version` would stay hand-rolled. The repo already chose Commander for its other CLIs; a second parser idiom for `dsh` alone is the fragmentation this change removes. + +**Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. + +**Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. + +## Testing + +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) drives the adapter directly: TUI defaults, config positional, `--resume` space/inline forms and their position-independence, empty/valueless/repeated `--resume` rejection, `-p`/`--prompt` routing with empty-prompt and stray-positional rejection, `web` host/port defaults and validation with `--host`/`--port` diagnostics, root flags misplaced around `web` failing loud, excess-argument rejection, and `--help`/`web --help`/`--version`/unknown-option outcomes. The `dsh CLI keyless smoke` group in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` exercises the real `bin.ts` dispatch end to end through a PTY (default boot, personal overlay, invalid config, `--resume` failure, source-path prompt) and stays green unchanged. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg` block. + +## Consequences + +`dsh` gains rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing no longer depends on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) now sitting on the CLI's front door. `dsh-app-boot` no longer owns any CLI-parsing surface; a future consumer needing `--resume`-style parsing composes Commander rather than reviving the deleted scanner. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md new file mode 100644 index 0000000000..ea37a1260e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 通过单个 Commander 适配器解析 `dsh` 的 argv + +Status: implemented + +[English](2026-07-24-dsh-commander-argument-adapter.md) | 中文 + +## 问题 + +`dsh` 的 CLI(命令行界面)入口(`apps/cli`)以三种手写方式解析 argv,这些方式无法组合,也不提供 `--help`/`--version`。`bin.ts` 通过原始检查进行分发:先判断 `argv[0] === 'web'`,再判断 `argv.includes('-p') || argv.includes('--prompt')`,否则走 TUI。这种方式对位置不敏感:位置错误的 prompt 标志或配置路径可能把模式路由错,而 `argv.includes('-p')` 无法区分真正的标志和偶然出现的 token。`headless.ts` 和 `web.ts` 各自运行自己的 `node:util` `parseArgs`,并内联校验 host/port,而 `dsh-app-boot` 携带 `parseResumeArg`——一个约 30 行的定制扫描器,为 `--resume` 重新实现了标志、`=` 形式、取值和重复的处理。用法说明只有一行硬编码的 `usage: dsh -p "task"`;既没有版本标志,也没有渲染出的帮助信息。 + +## 决策 + +argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(即 SDK bin,如 `create-sdk`、`dsh-scripts`,已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }`、`{ mode: 'web', host, port }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 + +`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个真正的 `program.command('web')` 子命令;`--host` 是 Commander 的 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。`--resume` 使用一个 `argParser`,同时拒绝空 id(`--resume=`)和重复出现的标志(`--resume a --resume b`),`--prompt` 则拒绝空任务,保留旧有的「绝不静默重新开始」不变式(已删除的 `parseResumeArg` 在相同情形下也会显式报错)。程序设置了 `enablePositionalOptions()`,且 `web` 动作会拒绝置于其前的根级 `--prompt`/`--resume`,因此位置错误的标志(`dsh web -p x`、`dsh -p x web`)会显式报错,而不会静默地以默认值提供服务。`--version` 读取本应用的 `package.json`。 + +`parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 + +## 包拓扑 + +参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。 + +## 考虑过的替代方案 + +**保留 `node:util` `parseArgs`,只统一分发。** 已否决:`parseArgs` 没有子命令模型、没有渲染出的帮助、也没有版本标志,因此 `web` 路由和 `--help`/`--version` 仍将保持手写。本仓库其他 CLI 已经选择了 Commander;单独为 `dsh` 引入第二套解析器方式,正是这次变更要消除的碎片化。 + +**保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 + +**把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 + +## 测试 + +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)直接驱动适配器:TUI 默认值、config 位置参数、`--resume` 的空格/内联形式及其位置无关性、对空值/无值/重复 `--resume` 的拒绝、`-p`/`--prompt` 路由及对空 prompt 和游离位置参数的拒绝、`web` 的 host/port 默认值与校验(含 `--host`/`--port` 诊断信息)、围绕 `web` 位置错误的根级标志会显式报错、对多余参数的拒绝,以及 `--help`/`web --help`/`--version`/未知选项的处理结果。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的 `dsh CLI keyless smoke` 组通过 PTY 端到端地运行真实的 `bin.ts` 分发(默认启动、个人覆盖、无效配置、`--resume` 失败、源路径 prompt),且保持绿色不变。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 测试块。 + +## 影响 + +`dsh` 获得了渲染出的 `--help`/`--version` 以及一致的显式报错式解析错误,模式路由也不再依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 新增一项 `commander` 依赖,且 Commander 的解析语义(它的错误字符串、它的 `exitOverride` 契约)如今落在 CLI 的入口处。`dsh-app-boot` 不再拥有任何 CLI 解析职责;未来需要 `--resume` 式解析的消费方应组合 Commander,而不是复活已删除的扫描器。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 87f5e670e3..15765090a0 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,6 +2,8 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)) that resolves the invocation into a single mode; `src/bin.ts` switches on that mode and dynamic-imports only the chosen mode's module. `dsh --help` and `dsh web --help` render usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. + The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); diff --git a/apps/cli/package.json b/apps/cli/package.json index fd744fa02c..791a44f98b 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,6 +19,7 @@ "@deepseek-ai/dsh-host-runtime": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "commander": "^15.0.0" } } diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts new file mode 100644 index 0000000000..f549c125c3 --- /dev/null +++ b/apps/cli/src/args.ts @@ -0,0 +1,183 @@ +/** + * Commander adapter for the `dsh` command-line entry: the one place argv is + * parsed and routed to a mode. `bin.ts` switches on the returned discriminant + * and dynamic-imports that mode's module; each mode module then consumes the + * already-parsed values instead of re-reading argv. Output is suppressed and + * `exitOverride` is set so Commander never writes or exits on its own — every + * outcome (including `--help`/`--version` and parse errors) is returned to the + * caller as data. + * @module @deepseek-ai/dsh/args + */ + +import { Command, CommanderError, InvalidArgumentError, Option } from 'commander' + +/** The loopback host `dsh web` binds by default. */ +export const LOOPBACK_HOST = '127.0.0.1' +/** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */ +export const ALL_INTERFACES_HOST = '0.0.0.0' +const DEFAULT_WEB_PORT = 3080 + +/** Interactive TUI: the default mode. Optional positional config and `--resume `. */ +interface TuiInvocation { + mode: 'tui' + config?: string + resume?: string +} + +/** Headless one-shot: `dsh -p "task"`. */ +interface HeadlessInvocation { + mode: 'headless' + prompt: string +} + +/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked. */ +interface WebInvocation { + mode: 'web' + host: string + port: number +} + +/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */ +interface InfoInvocation { + mode: 'help' | 'version' + text: string +} + +/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */ +interface ErrorInvocation { + mode: 'error' + message: string +} + +/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */ +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | WebInvocation + | InfoInvocation + | ErrorInvocation + +/** Raw Commander option bag for the root command before it is narrowed to a mode. */ +interface RootOptions { + prompt?: string + resume?: string +} + +/** Commander option bag for the `web` subcommand after `--port` coercion. */ +interface WebOptions { + host: string + port: number +} + +/** + * Coerce `--port` to an integer in 0–65535; a bad value throws + * {@link InvalidArgumentError}, which Commander reports as a parse error the + * adapter returns as an {@link ErrorInvocation}. + */ +function parsePort(raw: string): number { + const port = Number(raw) + if (!Number.isInteger(port) || port < 0 || port > 65535) { + throw new InvalidArgumentError(`invalid --port ${raw}`) + } + return port +} + +/** Reject an empty `--prompt` task; an empty headless prompt has nothing to run. */ +function parsePrompt(raw: string): string { + if (raw === '') throw new InvalidArgumentError("option '-p, --prompt ' must not be empty") + return raw +} + +/** + * Validate a `--resume` value: reject an empty id and a repeated flag. Both are + * mistypes that must fail loud, never silently start a fresh session or keep + * only the last id. `previous` is the value from an earlier `--resume` on the + * same invocation (Commander threads it in), so a second occurrence is caught. + */ +function parseResume(raw: string, previous: string | undefined): string { + if (previous !== undefined) throw new InvalidArgumentError("option '--resume ' may be given only once") + if (raw === '') throw new InvalidArgumentError("option '--resume ' must not be empty") + return raw +} + +/** + * Resolve the raw argv into a single {@link DshInvocation}. Never writes to a + * stream and never exits; `--help`/`--version` and every parse error come back + * as data for `bin.ts` to act on. + * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). + * @param version - the version string `--version` prints; read from this app's package.json. + * @returns the resolved invocation, discriminated by `mode`. + */ +export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { + let resolved: DshInvocation | undefined + const output: string[] = [] + + const program = new Command() + .name('dsh') + .description('dsh: interactive TUI, headless task, and browser UI') + .version(version, '-V, --version', 'output the version number') + .exitOverride() + .configureOutput({ + writeOut: chunk => void output.push(chunk), + writeErr: chunk => void output.push(chunk), + }) + + // Positional options keep `dsh -p x web` from routing to the `web` + // subcommand: a token after a root option is a positional, not a command. + program + .enablePositionalOptions() + .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') + .addOption(new Option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit').argParser(parsePrompt)) + .addOption(new Option('--resume ', 'resume the persisted session with this id (TUI mode)').argParser(parseResume)) + .action((config: string | undefined, options: RootOptions) => { + if (options.prompt !== undefined) { + // A headless prompt owns the invocation; a config positional is meaningless there. + if (config !== undefined) { + throw new InvalidArgumentError(`error: --prompt takes no config argument (got '${config}')`) + } + resolved = { mode: 'headless', prompt: options.prompt } + return + } + resolved = { + mode: 'tui', + ...config !== undefined ? { config } : {}, + ...options.resume !== undefined ? { resume: options.resume } : {}, + } + }) + + program + .command('web') + .description('serve the browser UI') + .addOption( + new Option('--host ', 'bind host') + .choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]) + .default(LOOPBACK_HOST), + ) + .addOption( + new Option('--port ', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort), + ) + .action((options: WebOptions, command: Command) => { + // Root options placed before `web` (`dsh -p x web`) leak onto the parent; + // reject them so a misplaced flag fails loud instead of silently serving. + const leaked = command.parent?.opts() + if (leaked?.prompt !== undefined || leaked?.resume !== undefined) { + throw new InvalidArgumentError('error: web takes no --prompt or --resume; place web first') + } + resolved = { mode: 'web', host: options.host, port: options.port } + }) + + try { + program.parse(argv, { from: 'user' }) + } catch (error) { + /* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */ + if (!(error instanceof CommanderError)) throw error + if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: output.join('') } + if (error.code === 'commander.version') return { mode: 'version', text: output.join('') } + // Every other CommanderError is a parse failure; its message is the diagnostic. + return { mode: 'error', message: error.message } + } + + /* v8 ignore next -- one action always resolves the invocation or parse throws above */ + if (resolved === undefined) throw new Error('dsh: argument parsing did not resolve a mode') + return resolved +} diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 1192472b98..5880c68407 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -1,25 +1,58 @@ #!/usr/bin/env node /** - * dsh — command-line entry. Coarse dispatch only; each surface module owns its - * argument handling. Dynamic imports keep unrelated surfaces out of each - * dispatch path; everything except `web` and headless prompts opens the TUI. + * dsh — command-line entry. Parses argv once through the Commander adapter and + * switches on the resolved mode; dynamic imports keep unrelated modes out of + * each dispatch path. `web` and headless prompts run their own module; + * everything else opens the TUI. `--help`/`--version` print and exit 0; a parse + * error prints to stderr and exits 1. * @module @deepseek-ai/dsh/bin */ /* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import { loadEnv } from '@deepseek-ai/dsh-app-boot' +import { parseDshArgs } from './args.ts' + +// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit +// one directory under apps/cli, so the checked-in manifest resolves with the +// same relative hop from either artifact. +/** This app's version, read from its checked-in package.json. */ +function readVersion(): string { + const manifest = JSON.parse( + readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'), + ) as { version?: unknown } + return typeof manifest.version === 'string' ? manifest.version : '0.0.0' +} loadEnv('dsh') -const argv = process.argv.slice(2) +const invocation = parseDshArgs(process.argv.slice(2), readVersion()) -if (argv[0] === 'web') { - const { runWeb } = await import('./web.ts') - await runWeb(argv.slice(1)) -} else if (argv.includes('-p') || argv.includes('--prompt')) { - const { runHeadless } = await import('./headless.ts') - await runHeadless(argv) -} else { - const { runTui } = await import('./tui.ts') - await runTui(argv) +switch (invocation.mode) { + case 'web': { + const { runWeb } = await import('./web.ts') + await runWeb(invocation.host, invocation.port) + break + } + case 'headless': { + const { runHeadless } = await import('./headless.ts') + await runHeadless(invocation.prompt) + break + } + case 'tui': { + const { runTui } = await import('./tui.ts') + await runTui(invocation.config, invocation.resume) + break + } + case 'help': + case 'version': + process.stdout.write(invocation.text) + process.exit(0) + case 'error': + process.stderr.write(`${invocation.message}\n`) + process.exit(1) + default: + invocation satisfies never + throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) } diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 303bac61f8..ccfd4c5f8a 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -7,7 +7,6 @@ * (completed → 0, else 1). */ -import { parseArgs } from 'node:util' import { startHost } from '@deepseek-ai/dsh-host-runtime' import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -65,17 +64,13 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, return { text, reason: 'error' } } -export async function runHeadless(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { prompt: { type: 'string', short: 'p' } }, - allowPositionals: false, - }) - const task = values.prompt - if (task === undefined || task === '') { - process.stderr.write('usage: dsh -p "task"\n') - process.exit(1) - } +/** + * Run one headless turn for `task` and exit (completed → 0, else 1). The task + * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` + * (the adapter rejects an empty task, so no guard is needed here). + * @param task - the prompt text for the single turn. + */ +export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6f97a68ad3..aa8fb9af5f 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,7 +18,6 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - parseResumeArg, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -45,11 +44,12 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) the tui-agent PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI from the invoking directory. - * @param argv - arguments after the subcommand dispatch; a `--resume ` flag - * resumes that persisted session, and the first non-flag argument may name a - * config to boot instead of the shipped default. + * @param config - a config path to boot instead of the shipped default, or + * `undefined` for the default; already parsed from the optional positional. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * already parsed and non-empty-validated from `--resume`. */ -export async function runTui(argv: string[]): Promise { +export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. @@ -63,9 +63,8 @@ export async function runTui(argv: string[]): Promise { loadEnv(NAME, resolveDshHome()) // An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it // after loadEnv and before boot reads it through the config's `!!js`. - const { resumeSessionId, rest } = parseResumeArg(argv) if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId - const ctx = await boot(NAME, resolveConfigPath(rest[0] ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) + const ctx = await boot(NAME, resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME)) addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 66a99bb577..106585529f 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -4,37 +4,19 @@ * concerns is this app module's job (packages stay single-sided). */ -import { parseArgs } from 'node:util' import { networkInterfaces } from 'node:os' import { createRequire } from 'node:module' import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime' import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts' -const LOOPBACK_HOST = '127.0.0.1' -const ALL_INTERFACES_HOST = '0.0.0.0' - -export async function runWeb(argv: string[]): Promise { - const { values } = parseArgs({ - args: argv, - options: { - host: { type: 'string', default: LOOPBACK_HOST }, - port: { type: 'string', default: '3080' }, - }, - allowPositionals: false, - }) - if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) { - process.stderr.write( - `dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`, - ) - process.exit(1) - } - const hostAddress = values.host - const port = Number(values.port) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - process.stderr.write(`dsh web: invalid --port ${values.port}\n`) - process.exit(1) - } - +/** + * Serve the browser UI. Host and port are already validated by the argument + * adapter (host constrained to loopback/all-interfaces, port a 0–65535 integer). + * @param hostAddress - the bind host: {@link LOOPBACK_HOST} or {@link ALL_INTERFACES_HOST}. + * @param port - the listen port; `0` lets the OS choose a free port. + */ +export async function runWeb(hostAddress: string, port: number): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ boot: { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts new file mode 100644 index 0000000000..ad6d0266ca --- /dev/null +++ b/apps/cli/tests/args.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' + +const VERSION = '1.2.3' +const parse = (argv: string[]) => parseDshArgs(argv, VERSION) + +/** Assert argv resolves to an error invocation whose message contains `needle`. */ +function expectError(argv: string[], needle: string): void { + const result = parse(argv) + expect(result.mode).toBe('error') + if (result.mode !== 'error') throw new Error('expected error mode') + expect(result.message).toContain(needle) +} + +describe('parseDshArgs — TUI (default mode)', () => { + it('defaults to the TUI with no config and no resume when given no arguments', () => { + expect(parse([])).toEqual({ mode: 'tui' }) + }) + + it('carries a positional config into the TUI mode', () => { + expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + }) + + it('parses --resume in the space and inline forms, independent of a config positional', () => { + expect(parse(['--resume', 'sess-1'])).toEqual({ mode: 'tui', resume: 'sess-1' }) + expect(parse(['--resume=sess-2'])).toEqual({ mode: 'tui', resume: 'sess-2' }) + expect(parse(['--resume', 'sess-3', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-3' }) + expect(parse(['app.yml', '--resume', 'sess-4'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-4' }) + }) + + it('fails loud on a valueless or empty --resume rather than silently starting fresh', () => { + expectError(['--resume'], '--resume') + expectError(['--resume='], 'must not be empty') + }) + + it('rejects a repeated --resume instead of silently keeping the last id', () => { + expectError(['--resume', 'a', '--resume', 'b'], 'may be given only once') + expectError(['--resume=a', '--resume=b'], 'may be given only once') + }) +}) + +describe('parseDshArgs — headless', () => { + it('routes -p / --prompt to the headless mode with the task text', () => { + expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + expect(parse(['--prompt', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + }) + + it('routes to headless regardless of the prompt flag position', () => { + // Positional-independent: the old `argv.includes('-p')` dispatch could not + // tell a real prompt flag from one buried after other tokens. + expect(parse(['-p', 'task'])).toEqual({ mode: 'headless', prompt: 'task' }) + }) + + it('rejects an empty prompt and a stray config positional', () => { + expectError(['-p', ''], 'must not be empty') + expectError(['-p', 'task', 'app.yml'], 'takes no config') + }) +}) + +describe('parseDshArgs — web', () => { + it('defaults the web mode to loopback and port 3080', () => { + expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080 }) + }) + + it('accepts an explicit loopback or all-interfaces host and a valid port', () => { + expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080'])) + .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080 }) + expect(parse(['web', '--port', '0'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 0 }) + }) + + it('rejects a non-integer or out-of-range port with a --port diagnostic', () => { + expectError(['web', '--port', 'abc'], '--port') + expectError(['web', '--port', '70000'], '--port') + expectError(['web', '--port', '-1'], '--port') + }) + + it('rejects a host outside the allowed choices with a --host diagnostic', () => { + expectError(['web', '--host', '10.0.0.1'], '--host') + }) + + it('rejects an unexpected positional after web', () => { + expectError(['web', 'extra'], 'too many arguments') + }) + + it('fails loud when a root flag is placed before web instead of serving with it dropped', () => { + // `dsh web -p x` and `dsh -p x web` both misrouted or dropped the flag under + // the old `argv[0]==='web'` / `argv.includes('-p')` dispatch. + expectError(['web', '-p', 'x'], "unknown option '-p'") + expectError(['web', '--resume', 'y'], "unknown option '--resume'") + expectError(['-p', 'x', 'web'], 'web takes no') + expectError(['--resume', 'y', 'web'], 'web takes no') + }) + + it('renders web usage for web --help', () => { + const help = parse(['web', '--help']) + expect(help.mode).toBe('help') + if (help.mode !== 'help') throw new Error('expected help mode') + expect(help.text).toContain('Usage: dsh web') + }) +}) + +describe('parseDshArgs — help, version, and errors', () => { + it('returns the rendered usage for --help / -h', () => { + const help = parse(['--help']) + expect(help.mode).toBe('help') + if (help.mode !== 'help') throw new Error('expected help mode') + expect(help.text).toContain('Usage: dsh') + expect(help.text).toContain('web') + expect(parse(['-h']).mode).toBe('help') + }) + + it('returns the version string for --version / -V', () => { + expect(parse(['--version'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) + expect(parse(['-V'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) + }) + + it('reports an unknown option as an error invocation', () => { + expectError(['--nope'], "unknown option '--nope'") + }) +}) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 24840b7fda..68f885608f 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -5,7 +5,6 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ | Export | Role | |---|---| | `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | -| `parseResumeArg(argv)` | Split the `--resume ` / `--resume=` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh | | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 2fd4ba4c05..faeb5a0e0a 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -36,50 +36,6 @@ export function resolveConfigPath( return resolve(dir, replayName) } -/** CLI flag the interactive surface accepts to resume a persisted session by id. */ -const RESUME_FLAG = '--resume' - -/** - * Split a leading `--resume ` / `--resume=` flag out of a CLI argument - * vector, returning the resumed session id (when the flag is present) and the - * remaining arguments with the flag and its value removed — so a positional - * config path stays readable regardless of the flag's position. A `--resume` - * with no following id, an empty id (`--resume=`), or a repeated `--resume` - * throws: a mistyped resume must fail loud, never silently start a fresh - * session. The id is not validated here; an unknown id fails loud downstream - * when the session cannot load. - * @param argv - the CLI arguments after subcommand dispatch. - * @returns the parsed resume id (or `undefined`) and the flag-stripped arguments. - */ -export function parseResumeArg( - argv: readonly string[], -): { resumeSessionId: string | undefined; rest: string[] } { - const rest: string[] = [] - let resumeSessionId: string | undefined - let skipNext = false - for (const [i, arg] of argv.entries()) { - if (skipNext) { - skipNext = false - continue - } - const inlineValue = arg.startsWith(`${RESUME_FLAG}=`) - if (arg === RESUME_FLAG || inlineValue) { - if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`) - const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1] - // A following token that is itself resume syntax (`--resume --resume x`) - // is a missing id, not a session literally named `--resume…`. - if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) { - throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} )`) - } - resumeSessionId = value - skipNext = !inlineValue // the space form consumed the following token as its value - continue - } - rest.push(arg) - } - return { resumeSessionId, rest } -} - /** * Load the optional gitignored `.env` from `dir`. Missing files fall back to the * ambient environment; other read failures are reported through `warn`. diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index 76e5238db7..d9934cb8bb 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -30,31 +30,6 @@ describe('resolveConfigPath', () => { }) }) -describe('parseResumeArg', () => { - it('returns no resume id and passes arguments through when the flag is absent', () => { - expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] }) - expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] }) - }) - - it('parses the space form, the inline form, and leaves a positional config path in any position', () => { - expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] }) - expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] }) - expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] }) - expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] }) - }) - - it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => { - expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once') - }) - - it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => { - expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id') - expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id') - }) -}) - describe('loadEnv', () => { it('loads variables from .env in the given dir', () => { const dir = tmp() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e28836146..e83b05f35c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + commander: + specifier: ^15.0.0 + version: 15.0.0 apps/web: dependencies: diff --git a/tsconfig.host.json b/tsconfig.host.json index f340f235da..5419347734 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,6 +8,7 @@ "rewriteRelativeImportExtensions": false }, "include": [ + "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", "examples/*/tests/**/*.ts", diff --git a/vitest.config.ts b/vitest.config.ts index 1177782a8a..fe704797f5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,6 +30,7 @@ const windowsCoverageExclusions = process.platform === 'win32' const testIncludes = [ 'packages/*/*/tests/**/*.spec.{ts,tsx}', + 'apps/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts', ] From 66585635c860f6b13ebc08a5e717fcd049d89319 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 19:47:23 +0800 Subject: [PATCH 15/31] 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 ee5132c1e190759c10daa6c0dd8fa47e08b27c28 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 20:01:38 +0800 Subject: [PATCH 16/31] refactor(cli): dispatch web as a reserved token, drop parse machinery Simplify the Commander adapter now that behavior can change: dispatch a leading `web` token to its own parser instead of a subcommand of the root program, and read opts()/processedArgs after parse() instead of action closures with a mutable holder. This removes enablePositionalOptions(), the parent-option leak guard, both action closures, and the --resume/--prompt argParser threading. Behavior changes: `dsh -p x web` is a headless prompt (extra positional dropped), `dsh web -p x` fails loud (web has no -p), and a repeated --resume is natural last-wins. The two real fail-loud invariants stay as post-parse checks: an empty --resume= id (agent-loop treats '' as no-resume) and an empty -p task. Trims args.spec.ts to the routing/fail-loud/help behavior that matters; the tui-agent keyless PTY smoke still covers bin.ts dispatch end to end. Net ~114 fewer lines across adapter and tests. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 6 +- ...07-24-dsh-commander-argument-adapter.zh.md | 6 +- apps/cli/src/args.ts | 177 ++++++++---------- apps/cli/tests/args.spec.ts | 119 ++---------- 5 files changed, 101 insertions(+), 211 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 5dd6055ffe..03b3c8e6f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: dc2830273b245d370feba0df6ed030045b8444ff -2026-07-24-dsh-commander-argument-adapter.zh.md: ea37a1260ebf81e787f02301bc6fc3c9438c4f75 +2026-07-24-dsh-commander-argument-adapter.md: 4decd926c7fffc8f7d24200f8b91044eaa1d00f1 +2026-07-24-dsh-commander-argument-adapter.zh.md: eaccc221d362804b0aa3081d0593e9dee8af1d4c diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index dc2830273b..4decd926c7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -12,7 +12,7 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port)` — none re-reads argv. `web` is a real `program.command('web')` subcommand; `--host` is a Commander `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser. `--resume` uses an `argParser` that rejects both an empty id (`--resume=`) and a repeated flag (`--resume a --resume b`), and `--prompt` rejects an empty task, preserving the old "never silently start fresh" invariant (the deleted `parseResumeArg` failed loud on the same cases). The program sets `enablePositionalOptions()`, and the `web` action rejects a root `--prompt`/`--resume` placed before it, so a misplaced flag (`dsh web -p x`, `dsh -p x web`) fails loud instead of silently serving with defaults. `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--version` reads this app's `package.json`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. @@ -26,11 +26,13 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages **Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. +**Make `web` a Commander subcommand of one root program** — rejected: a single program mixing a root `-p`/`--resume` grammar with a `web` subcommand leaks the root options onto `web` unless `enablePositionalOptions()` plus a parent-option guard are bolted on, which is exactly the kind of special-case machinery this change removes. Dispatching `web` as a reserved first token to a second parser is smaller and keeps the two grammars fully independent. + **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) drives the adapter directly: TUI defaults, config positional, `--resume` space/inline forms and their position-independence, empty/valueless/repeated `--resume` rejection, `-p`/`--prompt` routing with empty-prompt and stray-positional rejection, `web` host/port defaults and validation with `--host`/`--port` diagnostics, root flags misplaced around `web` failing loud, excess-argument rejection, and `--help`/`web --help`/`--version`/unknown-option outcomes. The `dsh CLI keyless smoke` group in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` exercises the real `bin.ts` dispatch end to end through a PTY (default boot, personal overlay, invalid config, `--resume` failure, source-path prompt) and stays green unchanged. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg` block. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape, the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. The `dsh CLI keyless smoke` group in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` exercises the real `bin.ts` dispatch end to end through a PTY (default boot, personal overlay, invalid config, `--resume` failure, source-path prompt) and stays green unchanged. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg` block. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index ea37a1260e..eaccc221d3 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -12,7 +12,7 @@ Status: implemented argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(即 SDK bin,如 `create-sdk`、`dsh-scripts`,已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }`、`{ mode: 'web', host, port }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 -`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个真正的 `program.command('web')` 子命令;`--host` 是 Commander 的 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。`--resume` 使用一个 `argParser`,同时拒绝空 id(`--resume=`)和重复出现的标志(`--resume a --resume b`),`--prompt` 则拒绝空任务,保留旧有的「绝不静默重新开始」不变式(已删除的 `parseResumeArg` 在相同情形下也会显式报错)。程序设置了 `enablePositionalOptions()`,且 `web` 动作会拒绝置于其前的根级 `--prompt`/`--resume`,因此位置错误的标志(`dsh web -p x`、`dsh -p x web`)会显式报错,而不会静默地以默认值提供服务。`--version` 读取本应用的 `package.json`。 +`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`),而 `dsh -p x web` 只是一个 headless prompt,其第二个位置参数被丢弃,无需防范任何跨命令泄漏。每个解析器都在 `parse()` 之后读取 Commander 的 `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 @@ -26,11 +26,13 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 **保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 +**把 `web` 做成单个根程序的 Commander 子命令。** 已否决:一个程序若把根级 `-p`/`--resume` 语法与 `web` 子命令混在一起,除非再加上 `enablePositionalOptions()` 和一个父级选项守卫,否则根级选项会泄漏到 `web` 上——而这正是这次变更要移除的那类特殊处理机制。把 `web` 作为保留的首个 token 分发给第二个解析器更小巧,且让两套语法完全独立。 + **把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)直接驱动适配器:TUI 默认值、config 位置参数、`--resume` 的空格/内联形式及其位置无关性、对空值/无值/重复 `--resume` 的拒绝、`-p`/`--prompt` 路由及对空 prompt 和游离位置参数的拒绝、`web` 的 host/port 默认值与校验(含 `--host`/`--port` 诊断信息)、围绕 `web` 位置错误的根级标志会显式报错、对多余参数的拒绝,以及 `--help`/`web --help`/`--version`/未知选项的处理结果。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的 `dsh CLI keyless smoke` 组通过 PTY 端到端地运行真实的 `bin.ts` 分发(默认启动、个人覆盖、无效配置、`--resume` 失败、源路径 prompt),且保持绿色不变。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 测试块。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)在关键层面覆盖适配器:按形态进行的模式路由、显式报错检查(空 resume/prompt、错误的 host/port、未知选项),以及 `--help`/`--version` 以数据形式呈现。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的 `dsh CLI keyless smoke` 组通过 PTY 端到端地运行真实的 `bin.ts` 分发(默认启动、个人覆盖、无效配置、`--resume` 失败、源路径 prompt),且保持绿色不变。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 测试块。 ## 影响 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index f549c125c3..e45393e8c6 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -5,7 +5,8 @@ * already-parsed values instead of re-reading argv. Output is suppressed and * `exitOverride` is set so Commander never writes or exits on its own — every * outcome (including `--help`/`--version` and parse errors) is returned to the - * caller as data. + * caller as data. The `web` subcommand is a reserved first token dispatched to + * its own parser, so root flags and `web` flags never share a grammar. * @module @deepseek-ai/dsh/args */ @@ -57,23 +58,7 @@ export type DshInvocation = | InfoInvocation | ErrorInvocation -/** Raw Commander option bag for the root command before it is narrowed to a mode. */ -interface RootOptions { - prompt?: string - resume?: string -} - -/** Commander option bag for the `web` subcommand after `--port` coercion. */ -interface WebOptions { - host: string - port: number -} - -/** - * Coerce `--port` to an integer in 0–65535; a bad value throws - * {@link InvalidArgumentError}, which Commander reports as a parse error the - * adapter returns as an {@link ErrorInvocation}. - */ +/** Coerce `--port` to an integer in 0–65535; a bad value fails loud as a parse error. */ function parsePort(raw: string): number { const port = Number(raw) if (!Number.isInteger(port) || port < 0 || port > 65535) { @@ -82,102 +67,90 @@ function parsePort(raw: string): number { return port } -/** Reject an empty `--prompt` task; an empty headless prompt has nothing to run. */ -function parsePrompt(raw: string): string { - if (raw === '') throw new InvalidArgumentError("option '-p, --prompt ' must not be empty") - return raw +/** + * A configured `Command` under `exitOverride` with output captured into `sink`, + * so `--help`, `--version`, and parse errors surface as thrown `CommanderError`s + * (see {@link settle}) rather than writing to a stream or exiting. + */ +function program(name: string, version: string, sink: string[]): Command { + return new Command() + .name(name) + .version(version, '-V, --version', 'output the version number') + .exitOverride() + .configureOutput({ + writeOut: chunk => void sink.push(chunk), + writeErr: chunk => void sink.push(chunk), + }) } /** - * Validate a `--resume` value: reject an empty id and a repeated flag. Both are - * mistypes that must fail loud, never silently start a fresh session or keep - * only the last id. `previous` is the value from an earlier `--resume` on the - * same invocation (Commander threads it in), so a second occurrence is caught. + * Run `command.parse` and map its thrown `CommanderError` to an info/error + * invocation, or `undefined` when the parse succeeded (the caller then reads the + * parsed options). */ -function parseResume(raw: string, previous: string | undefined): string { - if (previous !== undefined) throw new InvalidArgumentError("option '--resume ' may be given only once") - if (raw === '') throw new InvalidArgumentError("option '--resume ' must not be empty") - return raw +function settle(command: Command, argv: readonly string[], sink: string[]): InfoInvocation | ErrorInvocation | undefined { + try { + command.parse(argv, { from: 'user' }) + return undefined + } catch (error) { + /* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */ + if (!(error instanceof CommanderError)) throw error + if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: sink.join('') } + if (error.code === 'commander.version') return { mode: 'version', text: sink.join('') } + return { mode: 'error', message: error.message } + } +} + +/** Parse `dsh web` arguments (everything after the `web` token). */ +function parseWeb(argv: readonly string[], version: string): DshInvocation { + const sink: string[] = [] + const web = program('dsh web', version, sink) + .description('serve the browser UI') + .addOption(new Option('--host ', 'bind host').choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]).default(LOOPBACK_HOST)) + .addOption(new Option('--port ', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort)) + const settled = settle(web, argv, sink) + if (settled !== undefined) return settled + const { host, port } = web.opts<{ host: string; port: number }>() + return { mode: 'web', host, port } +} + +/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ +function parseRoot(argv: readonly string[], version: string): DshInvocation { + const sink: string[] = [] + const root = program('dsh', version, sink) + .description('dsh: interactive TUI, headless task, and browser UI') + .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + const settled = settle(root, argv, sink) + if (settled !== undefined) return settled + const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>() + const config = root.processedArgs[0] as string | undefined + + if (prompt !== undefined) { + // A headless prompt owns the invocation; an empty task has nothing to run. + if (prompt === '') return { mode: 'error', message: "error: option '-p, --prompt ' must not be empty" } + return { mode: 'headless', prompt } + } + // An empty `--resume=` id would silently start a fresh session downstream + // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. + if (resume === '') return { mode: 'error', message: "error: option '--resume ' must not be empty" } + return { + mode: 'tui', + ...config !== undefined ? { config } : {}, + ...resume !== undefined ? { resume } : {}, + } } /** * Resolve the raw argv into a single {@link DshInvocation}. Never writes to a * stream and never exits; `--help`/`--version` and every parse error come back - * as data for `bin.ts` to act on. + * as data for `bin.ts` to act on. A leading `web` token dispatches to the web + * parser; everything else is the default TUI/headless grammar. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param version - the version string `--version` prints; read from this app's package.json. * @returns the resolved invocation, discriminated by `mode`. */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { - let resolved: DshInvocation | undefined - const output: string[] = [] - - const program = new Command() - .name('dsh') - .description('dsh: interactive TUI, headless task, and browser UI') - .version(version, '-V, --version', 'output the version number') - .exitOverride() - .configureOutput({ - writeOut: chunk => void output.push(chunk), - writeErr: chunk => void output.push(chunk), - }) - - // Positional options keep `dsh -p x web` from routing to the `web` - // subcommand: a token after a root option is a positional, not a command. - program - .enablePositionalOptions() - .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') - .addOption(new Option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit').argParser(parsePrompt)) - .addOption(new Option('--resume ', 'resume the persisted session with this id (TUI mode)').argParser(parseResume)) - .action((config: string | undefined, options: RootOptions) => { - if (options.prompt !== undefined) { - // A headless prompt owns the invocation; a config positional is meaningless there. - if (config !== undefined) { - throw new InvalidArgumentError(`error: --prompt takes no config argument (got '${config}')`) - } - resolved = { mode: 'headless', prompt: options.prompt } - return - } - resolved = { - mode: 'tui', - ...config !== undefined ? { config } : {}, - ...options.resume !== undefined ? { resume: options.resume } : {}, - } - }) - - program - .command('web') - .description('serve the browser UI') - .addOption( - new Option('--host ', 'bind host') - .choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]) - .default(LOOPBACK_HOST), - ) - .addOption( - new Option('--port ', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort), - ) - .action((options: WebOptions, command: Command) => { - // Root options placed before `web` (`dsh -p x web`) leak onto the parent; - // reject them so a misplaced flag fails loud instead of silently serving. - const leaked = command.parent?.opts() - if (leaked?.prompt !== undefined || leaked?.resume !== undefined) { - throw new InvalidArgumentError('error: web takes no --prompt or --resume; place web first') - } - resolved = { mode: 'web', host: options.host, port: options.port } - }) - - try { - program.parse(argv, { from: 'user' }) - } catch (error) { - /* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */ - if (!(error instanceof CommanderError)) throw error - if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: output.join('') } - if (error.code === 'commander.version') return { mode: 'version', text: output.join('') } - // Every other CommanderError is a parse failure; its message is the diagnostic. - return { mode: 'error', message: error.message } - } - - /* v8 ignore next -- one action always resolves the invocation or parse throws above */ - if (resolved === undefined) throw new Error('dsh: argument parsing did not resolve a mode') - return resolved + return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) } diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ad6d0266ca..c9d3c236ad 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,120 +1,33 @@ import { describe, expect, it } from 'vitest' import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' -const VERSION = '1.2.3' -const parse = (argv: string[]) => parseDshArgs(argv, VERSION) +const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') -/** Assert argv resolves to an error invocation whose message contains `needle`. */ -function expectError(argv: string[], needle: string): void { - const result = parse(argv) - expect(result.mode).toBe('error') - if (result.mode !== 'error') throw new Error('expected error mode') - expect(result.message).toContain(needle) -} - -describe('parseDshArgs — TUI (default mode)', () => { - it('defaults to the TUI with no config and no resume when given no arguments', () => { +describe('parseDshArgs', () => { + it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) - }) - - it('carries a positional config into the TUI mode', () => { expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) - }) - - it('parses --resume in the space and inline forms, independent of a config positional', () => { - expect(parse(['--resume', 'sess-1'])).toEqual({ mode: 'tui', resume: 'sess-1' }) - expect(parse(['--resume=sess-2'])).toEqual({ mode: 'tui', resume: 'sess-2' }) - expect(parse(['--resume', 'sess-3', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-3' }) - expect(parse(['app.yml', '--resume', 'sess-4'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess-4' }) - }) - - it('fails loud on a valueless or empty --resume rather than silently starting fresh', () => { - expectError(['--resume'], '--resume') - expectError(['--resume='], 'must not be empty') - }) - - it('rejects a repeated --resume instead of silently keeping the last id', () => { - expectError(['--resume', 'a', '--resume', 'b'], 'may be given only once') - expectError(['--resume=a', '--resume=b'], 'may be given only once') - }) -}) - -describe('parseDshArgs — headless', () => { - it('routes -p / --prompt to the headless mode with the task text', () => { + expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['--prompt', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - }) - - it('routes to headless regardless of the prompt flag position', () => { - // Positional-independent: the old `argv.includes('-p')` dispatch could not - // tell a real prompt flag from one buried after other tokens. - expect(parse(['-p', 'task'])).toEqual({ mode: 'headless', prompt: 'task' }) - }) - - it('rejects an empty prompt and a stray config positional', () => { - expectError(['-p', ''], 'must not be empty') - expectError(['-p', 'task', 'app.yml'], 'takes no config') - }) -}) - -describe('parseDshArgs — web', () => { - it('defaults the web mode to loopback and port 3080', () => { expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080 }) - }) - - it('accepts an explicit loopback or all-interfaces host and a valid port', () => { expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080'])) .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080 }) - expect(parse(['web', '--port', '0'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 0 }) }) - it('rejects a non-integer or out-of-range port with a --port diagnostic', () => { - expectError(['web', '--port', 'abc'], '--port') - expectError(['web', '--port', '70000'], '--port') - expectError(['web', '--port', '-1'], '--port') + it('fails loud instead of silently starting fresh or serving on bad input', () => { + // An empty resume/prompt would otherwise be swallowed (agent-loop treats an + // empty resume id as no-resume); a bad host/port must not reach the listener. + expect(parse(['--resume=']).mode).toBe('error') + expect(parse(['-p', '']).mode).toBe('error') + expect(parse(['web', '--host', '10.0.0.1']).mode).toBe('error') + expect(parse(['web', '--port', 'abc']).mode).toBe('error') + expect(parse(['--bogus']).mode).toBe('error') }) - it('rejects a host outside the allowed choices with a --host diagnostic', () => { - expectError(['web', '--host', '10.0.0.1'], '--host') - }) - - it('rejects an unexpected positional after web', () => { - expectError(['web', 'extra'], 'too many arguments') - }) - - it('fails loud when a root flag is placed before web instead of serving with it dropped', () => { - // `dsh web -p x` and `dsh -p x web` both misrouted or dropped the flag under - // the old `argv[0]==='web'` / `argv.includes('-p')` dispatch. - expectError(['web', '-p', 'x'], "unknown option '-p'") - expectError(['web', '--resume', 'y'], "unknown option '--resume'") - expectError(['-p', 'x', 'web'], 'web takes no') - expectError(['--resume', 'y', 'web'], 'web takes no') - }) - - it('renders web usage for web --help', () => { - const help = parse(['web', '--help']) - expect(help.mode).toBe('help') - if (help.mode !== 'help') throw new Error('expected help mode') - expect(help.text).toContain('Usage: dsh web') - }) -}) - -describe('parseDshArgs — help, version, and errors', () => { - it('returns the rendered usage for --help / -h', () => { + it('surfaces --help and --version as printable data, not a process exit', () => { const help = parse(['--help']) - expect(help.mode).toBe('help') - if (help.mode !== 'help') throw new Error('expected help mode') - expect(help.text).toContain('Usage: dsh') - expect(help.text).toContain('web') - expect(parse(['-h']).mode).toBe('help') - }) - - it('returns the version string for --version / -V', () => { - expect(parse(['--version'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) - expect(parse(['-V'])).toEqual({ mode: 'version', text: `${VERSION}\n` }) - }) - - it('reports an unknown option as an error invocation', () => { - expectError(['--nope'], "unknown option '--nope'") + expect(help).toMatchObject({ mode: 'help' }) + if (help.mode === 'help') expect(help.text).toContain('Usage: dsh') + expect(parse(['--version'])).toEqual({ mode: 'version', text: '1.2.3\n' }) }) }) From 04c8a17de5e48cd3b5f4c3d485baf86323625733 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 20:08:38 +0800 Subject: [PATCH 17/31] 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 18/31] 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 19/31] 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 870fb1cafa32feeac857b1ca62028df79b843a25 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 12:43:59 +0800 Subject: [PATCH 20/31] refactor(cli): make dsh the sole terminal front door, drop RESUME_SESSION_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the redundant dsh-tui-demo bin and the RESUME_SESSION_ID environment variable, leaving dsh as the one terminal entrypoint. The dsh-tui-demo package was a plugin (the TUI app bundle mounted by dsh's config) plus a bin that booted a leaf cordis.yml — the same job `dsh [config]` does. The bin, its ./bin export, its built-bin.e2e.ts, the tsdown bin entry, and the now-unused dsh-app-boot dependency are removed; the package keeps its plugin and invariant. demo:cordis, demo:code-mode, and the tui-agent and cordis-agent keyless PTY smokes now launch through apps/cli/src/bin.ts with the config as the positional argument. cli-demo/acp-demo/jsonrpc-demo keep their bins (distinct surfaces). RESUME_SESSION_ID was the only bridge from --resume into the shipped config; --resume now provides the id on the boot context via ctx.provide( RESUME_SESSION_ID_KEY, id), and the four configs read it as a bare identifier through a quoted typeof-guarded !!js expression. The TUI resumeCommand fixtures and docs move to `dsh --resume {session}`. Agent Note and its Chinese pair updated; config-catalog regenerated. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 20 +++- ...07-24-dsh-commander-argument-adapter.zh.md | 20 +++- ...07-20-retire-readline-front-door.i18n.yaml | 4 +- .../2026-07-20-retire-readline-front-door.md | 2 +- ...026-07-20-retire-readline-front-door.zh.md | 2 +- apps/cli/README.md | 2 +- apps/cli/src/args.ts | 5 +- docs/config-catalog.md | 4 +- examples/README.md | 2 +- .../cordis-agent/tests/keyless-smoke.e2e.ts | 2 +- examples/tui-agent/README.md | 2 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 5 +- knip.json | 3 +- package.json | 2 +- packages/examples/README.md | 4 +- packages/examples/tui-demo/README.md | 8 +- packages/examples/tui-demo/package.json | 12 +-- packages/examples/tui-demo/src/bin.ts | 27 ----- packages/examples/tui-demo/src/index.ts | 4 +- .../examples/tui-demo/tests/built-bin.e2e.ts | 98 ------------------- packages/examples/tui-demo/tsdown.config.ts | 12 +-- .../loader-smoke/tests/example-launch.spec.ts | 6 +- packages/ui/app-boot/README.md | 2 +- packages/ui/tui/tests/tui.snapshot.ts | 2 +- packages/ui/tui/tests/tui.spec.ts | 6 +- pnpm-lock.yaml | 3 - scripts/demo-code-mode.mjs | 2 +- 28 files changed, 76 insertions(+), 189 deletions(-) delete mode 100644 packages/examples/tui-demo/src/bin.ts delete mode 100644 packages/examples/tui-demo/tests/built-bin.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 03b3c8e6f7..1780a5f57e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: 4decd926c7fffc8f7d24200f8b91044eaa1d00f1 -2026-07-24-dsh-commander-argument-adapter.zh.md: eaccc221d362804b0aa3081d0593e9dee8af1d4c +2026-07-24-dsh-commander-argument-adapter.md: 60c47ef40cb0db833f7a2a526437b6a8ce812433 +2026-07-24-dsh-commander-argument-adapter.zh.md: 41a98499036c16330263d5072aa0fa454b892a24 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index 4decd926c7..60c47ef40c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -10,12 +10,20 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di ## Decision -Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. +Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port, dev }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser; `--dev` mounts the client HMR driver and bundle watch. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--version` reads this app's `package.json`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. +## Resume without an environment variable + +Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a bin that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly as `dsh [config] --resume ` from the parsed values, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. + +## One terminal front door: `dsh` + +The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the `built-bin.e2e.ts` (its TUI piped-launch refusal is covered by `dsh`'s own TTY guard in the tui-agent PTY smoke), and the tsdown `bin` entry all leave with it. `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. + ## Package topology The argument surface stays inside `apps/cli`, the assembly tier, not a `packages/*` library: it is this one app's routing, not a reusable seam. `dsh-app-boot` shrinks to boot glue with no CLI-parsing responsibility. `commander@^15` is added to `apps/cli/package.json`, matching the SDK bins' pin. @@ -30,10 +38,14 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. +**Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`. + +**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh [config]` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. + ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape, the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. The `dsh CLI keyless smoke` group in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` exercises the real `bin.ts` dispatch end to end through a PTY (default boot, personal overlay, invalid config, `--resume` failure, source-path prompt) and stays green unchanged. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg` block. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences -`dsh` gains rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing no longer depends on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) now sitting on the CLI's front door. `dsh-app-boot` no longer owns any CLI-parsing surface; a future consumer needing `--resume`-style parsing composes Commander rather than reviving the deleted scanner. +`dsh` gains rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing no longer depends on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) now sitting on the CLI's front door. `dsh-app-boot` no longer owns any CLI-parsing surface; a future consumer needing `--resume`-style parsing composes Commander rather than reviving the deleted scanner. Resuming a session needs no environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is now a plugin bundle with no bin. Anyone who ran `dsh-tui-demo ` or `RESUME_SESSION_ID= dsh-tui-demo` uses `dsh ` / `dsh --resume ` instead. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index eaccc221d3..41a9849903 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -10,12 +10,20 @@ Status: implemented ## 决策 -argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(即 SDK bin,如 `create-sdk`、`dsh-scripts`,已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }`、`{ mode: 'web', host, port }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 +argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(即 SDK bin,如 `create-sdk`、`dsh-scripts`,已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }`、`{ mode: 'web', host, port, dev }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 -`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`),而 `dsh -p x web` 只是一个 headless prompt,其第二个位置参数被丢弃,无需防范任何跨命令泄漏。每个解析器都在 `parse()` 之后读取 Commander 的 `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`。 +`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`),而 `dsh -p x web` 只是一个 headless prompt,其第二个位置参数被丢弃,无需防范任何跨命令泄漏。每个解析器都在 `parse()` 之后读取 Commander 的 `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器;`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 +## 无需环境变量即可恢复 + +将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的 bin 也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh [config] --resume `,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 + +## 唯一的终端入口:`dsh` + +`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、`built-bin.e2e.ts`(其中拒绝通过管道启动 TUI 的行为已由 tui-agent PTY 冒烟测试中 `dsh` 自身的 TTY 守卫覆盖),以及 tsdown 的 `bin` 入口。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 + ## 包拓扑 参数解析留在 `apps/cli`(组装层)内,而不是 `packages/*` 库中:它是这一个应用自身的路由,而非可复用的 seam。`dsh-app-boot` 收缩为纯粹的 boot 胶水代码,不再承担 CLI 解析职责。`commander@^15` 被加入 `apps/cli/package.json`,与 SDK bin 锁定的版本一致。 @@ -30,10 +38,14 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 **把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 +**保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。 + +**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh [config]` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 + ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)在关键层面覆盖适配器:按形态进行的模式路由、显式报错检查(空 resume/prompt、错误的 host/port、未知选项),以及 `--help`/`--version` 以数据形式呈现。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的 `dsh CLI keyless smoke` 组通过 PTY 端到端地运行真实的 `bin.ts` 分发(默认启动、个人覆盖、无效配置、`--resume` 失败、源路径 prompt),且保持绿色不变。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 测试块。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态选择模式(包括 `web --dev`)、显式报错场景(空的恢复会话 id、空提示词、非法主机、非法端口和未知选项),以及将 `--help` 和 `--version` 作为数据返回。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 -`dsh` 获得了渲染出的 `--help`/`--version` 以及一致的显式报错式解析错误,模式路由也不再依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 新增一项 `commander` 依赖,且 Commander 的解析语义(它的错误字符串、它的 `exitOverride` 契约)如今落在 CLI 的入口处。`dsh-app-boot` 不再拥有任何 CLI 解析职责;未来需要 `--resume` 式解析的消费方应组合 Commander,而不是复活已删除的扫描器。 +`dsh` 获得了渲染出的 `--help`/`--version` 以及一致的显式报错式解析错误,模式路由也不再依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 新增一项 `commander` 依赖,且 Commander 的解析语义(它的错误字符串、它的 `exitOverride` 契约)如今落在 CLI 的入口处。`dsh-app-boot` 不再拥有任何 CLI 解析职责;未来需要 `--resume` 式解析的消费方应组合 Commander,而不是复活已删除的扫描器。恢复会话不再需要环境变量,且 `dsh` 是唯一的终端入口;`dsh-tui-demo` 包现在是一个不带 bin 的插件组合包。原先运行 `dsh-tui-demo ` 或 `RESUME_SESSION_ID= dsh-tui-demo` 的用户,改用 `dsh ` 或 `dsh --resume `。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml index 232fec495b..1e1968e09d 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.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-20-retire-readline-front-door.md: 7ebcfdc246bdf6971418609c61acbd4019aa90cb -2026-07-20-retire-readline-front-door.zh.md: cf4d03594ed3a0cf31bed96eb2133bd37959084a +2026-07-20-retire-readline-front-door.md: d8e6a5c172b576ce6bc76911186c9f81a4ece88f +2026-07-20-retire-readline-front-door.zh.md: bea685cdc3f8f1530d56ae3eb5dcc34cff0b46af diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md index 7ebcfdc246..d8e6a5c172 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md @@ -26,7 +26,7 @@ Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned - `examples/echo-agent/tests/echo.e2e.ts` proves the Loader boot + mock-model tool round-trip through `stream-json` records instead of readline transcript lines. - The CI demo-smoke gate (`scripts/run-gates.ts`, AGENTS.md) runs `demo:echo --output-format stream-json -p "echo ci smoke"` and parses the records structurally. -- `packages/examples/tui-demo/tests/built-bin.e2e.ts` proves the built bin's piped-launch refusal (nonzero exit + pointer at `dsh-cli-demo`); the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. +- The TUI's piped-launch refusal (nonzero exit + pointer at the one-shot CLI) is covered by the `dsh` TTY guard exercised in `examples/tui-agent`'s PTY smoke; the echo-round-trip-under-plain-Node and missing-config fail-loud proofs live in `cli-demo`'s built-bin suite. - `packages/context/time-context/tests/time-context.e2e.ts` runs one one-shot turn; multi-turn elapsed rendering stays unit-covered in its spec. ## Accepted losses diff --git a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md index cf4d03594e..bea685cdc3 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md @@ -26,7 +26,7 @@ Status: implemented - `examples/echo-agent/tests/echo.e2e.ts` 通过 `stream-json` 记录证明 Loader 启动 + mock 模型的工具往返,而不是匹配 readline 文本记录行。 - CI 演示冒烟门禁(`scripts/run-gates.ts`、AGENTS.md)运行 `demo:echo --output-format stream-json -p "echo ci smoke"` 并结构化解析记录。 -- `packages/examples/tui-demo/tests/built-bin.e2e.ts` 证明构建产物 bin 对管道启动的拒绝(非零退出 + 指向 `dsh-cli-demo` 的提示);纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 +- TUI 对管道启动的拒绝(非零退出 + 指向单次任务 CLI 的提示)由 `examples/tui-agent` 的 PTY 冒烟测试所行使的 `dsh` TTY 守卫覆盖;纯 Node 下的 echo 往返证明与缺失配置的快速失败证明位于 `cli-demo` 的 built-bin 套件。 - `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。 ## 接受的损失 diff --git a/apps/cli/README.md b/apps/cli/README.md index 43360ef589..d94b577552 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -7,7 +7,7 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 61e99cca44..37e87a1db7 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -31,7 +31,10 @@ interface HeadlessInvocation { prompt: string } -/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch. */ +/** + * Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; + * port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch. + */ interface WebInvocation { mode: 'web' host: string diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..dec711cfda 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1665,8 +1665,8 @@ export interface Config { /** * Shell command template the TUI prints on exit and lists under `/resume`, * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes via this app's env var, e.g. - * `RESUME_SESSION_ID={session} dsh`. + * door). Set it to a command that resumes the session, e.g. + * `dsh --resume {session}`. */ resumeCommand?: string /** Full-screen TUI presentation settings. */ diff --git a/examples/README.md b/examples/README.md index b895259965..cf244eb4ba 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the `demo:*` scripts invoke each app package's bin. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts index 6e5cca3b08..c340eea036 100644 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ b/examples/cordis-agent/tests/keyless-smoke.e2e.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) +const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 2e87df0a27..5196de053b 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -27,7 +27,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. The flag sets `RESUME_SESSION_ID`, wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); the env var still works directly for the uninstalled demo (`RESUME_SESSION_ID= pnpm run demo:tui`), and with neither set the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index a1366621a0..c464fa2a96 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -8,7 +8,6 @@ import { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/d import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -const binScript = fileURLToPath(new URL('../../../packages/examples/tui-demo/src/bin.ts', import.meta.url)) const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) @@ -87,11 +86,11 @@ async function readLoggedSystemPrompt(cwd: string): Promise { throw new Error(`session log ${logRelPath} has no request/header event`) } -/** Shared defaults: the keyless key, the tui-demo bin, and the live cordis.yml. */ +/** Shared defaults: the keyless key, the dsh bin, and the live cordis.yml (passed as the positional config). */ function smoke(overrides: Partial & { label: string }): Promise { return runTuiPtySmoke({ tempDirPrefix: 'tui-agent-smoke-', - binScript, + binScript: dshBinScript, configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, diff --git a/knip.json b/knip.json index 59658e1df6..da6dc977e6 100644 --- a/knip.json +++ b/knip.json @@ -417,8 +417,7 @@ }, "packages/examples/tui-demo": { "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.spec.ts" ], "project": [ "src/**/*.ts", diff --git a/package.json b/package.json index 3ff149b80a..3d534c5d39 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx packages/examples/tui-demo/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --import tsx apps/cli/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", diff --git a/packages/examples/README.md b/packages/examples/README.md index d247577b44..8c6eaddfc9 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` | +| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 10ff7872c5..b6c80687a4 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-tui-demo -The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`. +The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config. -Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback. +Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback. ## What it bakes in @@ -47,9 +47,9 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. -## The bin +## Front door -`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. +This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: `dsh [path-to-cordis.yml]` boots a leaf config that mounts this bundle (defaulting to the shipped `examples/tui-agent/cordis.yml`), loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. ## Example leaf diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 1ddf5060b1..26e3e64183 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -1,14 +1,11 @@ { "name": "@deepseek-ai/dsh-tui-demo", - "description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent", + "description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)", "version": "0.0.1", "private": true, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", - "bin": { - "dsh-tui-demo": "lib/bin.js" - }, "exports": { ".": { "types": "./lib/types/index.d.ts", @@ -18,17 +15,12 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, - "./bin": { - "types": "./lib/types/bin.d.ts", - "default": "./lib/bin.js" - }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", - "lib/bin.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -37,7 +29,6 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-commands": "^0.0.1", @@ -62,7 +53,6 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", diff --git a/packages/examples/tui-demo/src/bin.ts b/packages/examples/tui-demo/src/bin.ts deleted file mode 100644 index 5073e203df..0000000000 --- a/packages/examples/tui-demo/src/bin.ts +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -/** - * Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the - * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs. - * @module @deepseek-ai/dsh-tui-demo/bin - */ - -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' - -const NAME = 'dsh-tui-demo' - -/* v8 ignore start -- thin self-executing composition over the unit-tested - dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and - the built-bin fail-loud smoke */ -// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is -// logged per-entry rather than rethrown, so a piped launch would otherwise -// settle into an idle UI-less process instead of exiting nonzero. -if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; ` - + 'use the one-shot dsh-cli-demo bin for pipes and automation\n') - process.exit(1) -} -installFailLoud(NAME) -loadEnv(NAME) -await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined)) -/* v8 ignore stop */ diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index 29f985c8e7..8a88859ab3 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -64,8 +64,8 @@ export interface Config { /** * Shell command template the TUI prints on exit and lists under `/resume`, * with `{session}` replaced by the live session id (forwarded to the front - * door). Set it to a command that resumes via this app's env var, e.g. - * `RESUME_SESSION_ID={session} dsh`. + * door). Set it to a command that resumes the session, e.g. + * `dsh --resume {session}`. */ resumeCommand?: string /** Full-screen TUI presentation settings. */ diff --git a/packages/examples/tui-demo/tests/built-bin.e2e.ts b/packages/examples/tui-demo/tests/built-bin.e2e.ts deleted file mode 100644 index 6a793bf104..0000000000 --- a/packages/examples/tui-demo/tests/built-bin.e2e.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { spawn } from 'node:child_process' -import { existsSync } from 'node:fs' -import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it } from 'vitest' - -/** - * Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer. - * The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a - * nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader - * because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer - * links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal - * fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and - * full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it - * skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the - * one sanctioned PTY surface). - */ - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js') - -// Symlink each package the bin imports at module load by package name so plain -// Node resolves its built `main`, matching an installed dependency rather than -// tsconfig paths. -const dshPackages = ['examples/tui-demo', 'ui/app-boot'] -const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit'] - -async function pkgName(absDir: string): Promise { - const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string } - return json.name -} - -/** Build a temporary external consumer with built workspace/vendor links. */ -async function makeConsumer(): Promise { - const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-')) - const nm = join(dir, 'node_modules') - for (const rel of dshPackages) { - const abs = join(repoRoot, 'packages', rel) - const target = join(nm, await pkgName(abs)) - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - for (const v of vendorPackages) { - const abs = join(repoRoot, 'vendor', v) - const target = join(nm, await pkgName(abs)) - await mkdir(dirname(target), { recursive: true }) - await symlink(abs, target) - } - return dir -} - -/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */ -function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> { - return new Promise((resolve, reject) => { - // NO tsx — this is the published `node lib/bin.js` path; the guard fires - // before the Loader resolves the config tree. - const child = spawn(process.execPath, [tuiBin, './cordis.yml'], { - cwd, - env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') }, - stdio: ['pipe', 'pipe', 'pipe'], - }) - let stdout = '' - let stderr = '' - child.stdout.setEncoding('utf8') - child.stdout.on('data', (c: string) => { stdout += c }) - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => { stderr += c }) - const timer = setTimeout(() => { - child.kill('SIGKILL') - reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) - }, 25_000) - child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) - child.on('error', (err) => { clearTimeout(timer); reject(err) }) - child.stdin.end() - }) -} - -let consumer: string | undefined - -afterEach(async () => { - // Windows can briefly retain released handles after exit; retry removal. - if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) - consumer = undefined -}) - -describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => { - it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { - consumer = await makeConsumer() - const { stdout, code, stderr } = await runBuiltBin(consumer) - expect(code).not.toBe(0) - expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') - expect(stderr).toContain('dsh-cli-demo') - // The refusal happens before any plugin mounts: stdout stays silent. - expect(stdout).toBe('') - }, 30_000) -}) diff --git a/packages/examples/tui-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts index 06efc0b4db..1033dc08df 100644 --- a/packages/examples/tui-demo/tsdown.config.ts +++ b/packages/examples/tui-demo/tsdown.config.ts @@ -1,14 +1,14 @@ import { defineConfig } from 'tsdown' /** - * tui-demo ships two entries: the plugin (`index`) and the CLI `bin` - * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. - * The root tsdown builds only `lib/types/index.js`, so this override adds - * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), - * matching every package. + * tui-demo ships the plugin (`index`) and its invariant companion; the CLI + * front door is `dsh` (apps/cli), which mounts this bundle through its config. + * The root tsdown builds only `lib/types/index.js`, so this override adds the + * invariant entry. Declarations come from `tsc -b` (dts: false), matching + * every package. */ export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], + entry: ['lib/types/index.js', 'lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/support/loader-smoke/tests/example-launch.spec.ts b/packages/support/loader-smoke/tests/example-launch.spec.ts index 8033645d53..8cf75d3be9 100644 --- a/packages/support/loader-smoke/tests/example-launch.spec.ts +++ b/packages/support/loader-smoke/tests/example-launch.spec.ts @@ -5,7 +5,7 @@ import { resolveExampleMode, } from '@deepseek-ai/dsh-loader-smoke' -const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts' +const SRC_BIN = '/repo/packages/examples/cli-demo/src/bin.ts' const TSCONFIG = '/repo/tsconfig.json' const originalMode = process.env[EXAMPLE_MODE_ENV] @@ -65,7 +65,7 @@ describe('resolveExampleLaunch', () => { env: { DSH_HOME: '/tmp/home' }, }) expect(args).not.toContain('--import') - expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js') expect(args.slice(-2)).toEqual(['--config', './cordis.yml']) expect(env.TSX_TSCONFIG_PATH).toBeUndefined() expect(env.DSH_HOME).toBe('/tmp/home') @@ -100,6 +100,6 @@ describe('resolveExampleLaunch', () => { it('defaults the mode from the environment', () => { process.env[EXAMPLE_MODE_ENV] = 'lib' const { args } = resolveExampleLaunch({ srcBin: SRC_BIN }) - expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js') + expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js') }) }) diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index da44317c94..efc5575950 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -1,6 +1,6 @@ # `@deepseek-ai/dsh-app-boot` -Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. +Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts. | Export | Role | |---|---| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index d837841286..82830a5ca0 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -650,7 +650,7 @@ describe('TUI terminal-state snapshots', () => { const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z')) const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' } const harness = await setupSnapshot({ - config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' }, + config: { resumeCommand: 'dsh --resume {session}' }, sessionPersistence: { list: async () => [earlier], load: async () => ({ diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 0fea8b2750..a1d9848678 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -206,7 +206,7 @@ describe('TUI config', () => { }) describe('resume command and /resume', () => { - const RESUME = 'RESUME_SESSION_ID={session} dsh' + const RESUME = 'dsh --resume {session}' const header = (id: string, createdAt: number, cwd: string): SessionHeader => ({ version: 0, id: SessionId(id), createdAt, cwd }) const resumeEvents = ( @@ -234,7 +234,7 @@ describe('resume command and /resume', () => { result.terminal.send('/exit') result.terminal.send('\r') await tick() - expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh') + expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session') expect(result.exit).toHaveBeenCalledWith(0) await dispose(result) }) @@ -1000,7 +1000,7 @@ describe('resume command and /resume', () => { result.terminal.send('\r') await tick() expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:') - expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session') + expect(result.terminal.output).toContain('dsh --resume fallback-session') expect(result.terminal.stopped).toBe(0) await dispose(result) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15baee1425..0cccc15282 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1554,9 +1554,6 @@ importers: '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:^ version: link:../agent-spine-demo - '@deepseek-ai/dsh-app-boot': - specifier: workspace:^ - version: link:../../ui/app-boot '@deepseek-ai/dsh-command-goal': specifier: workspace:^ version: link:../../goal/command-goal diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index c3e7849a6b..7b06b859f2 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,7 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'packages/examples/tui-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) From 0901140b3fa6cd6206a67c29f55091ed1962b49f Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 13:01:45 +0800 Subject: [PATCH 21/31] test(cli): cover the dsh built-bin non-TTY refusal Removing the dsh-tui-demo bin dropped the only test of the TUI's piped-launch refusal. Add apps/cli/tests/built-bin.e2e.ts (apps/*/tests added to the e2e vitest include) running the built lib/bin.js under plain Node with piped stdio, and point the refusal message at `dsh -p "task"` for automation. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 2 +- ...07-24-dsh-commander-argument-adapter.zh.md | 2 +- apps/cli/src/tui.ts | 4 +- apps/cli/tests/built-bin.e2e.ts | 54 +++++++++++++++++++ vitest.e2e.config.ts | 2 +- 6 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 apps/cli/tests/built-bin.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 1780a5f57e..3a85dedb0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: 60c47ef40cb0db833f7a2a526437b6a8ce812433 -2026-07-24-dsh-commander-argument-adapter.zh.md: 41a98499036c16330263d5072aa0fa454b892a24 +2026-07-24-dsh-commander-argument-adapter.md: c038f4facdc62039b8a31be5d660648fd81aebd2 +2026-07-24-dsh-commander-argument-adapter.zh.md: 285917af7c4b0da769ae7595bb1a6e5966adafd9 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index 60c47ef40c..c038f4facd 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -22,7 +22,7 @@ Merging the concurrent safe-session-resume feature onto this parser retired the ## One terminal front door: `dsh` -The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the `built-bin.e2e.ts` (its TUI piped-launch refusal is covered by `dsh`'s own TTY guard in the tui-agent PTY smoke), and the tsdown `bin` entry all leave with it. `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. +The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. ## Package topology diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 41a9849903..285917af7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -22,7 +22,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、`built-bin.e2e.ts`(其中拒绝通过管道启动 TUI 的行为已由 tui-agent PTY 冒烟测试中 `dsh` 自身的 TTY 守卫覆盖),以及 tsdown 的 `bin` 入口。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 +`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 ## 包拓扑 diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 819cb22e42..6ddad89302 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -53,7 +53,9 @@ export async function runTui(config: string | undefined, resumeSessionId: string // is logged per-entry rather than rethrown, so a piped launch would // otherwise settle into an idle UI-less process instead of exiting nonzero. if (!process.stdin.isTTY || !process.stdout.isTTY) { - process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs\n`) + process.stderr.write( + `${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`, + ) process.exit(1) } installFailLoud(NAME) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts new file mode 100644 index 0000000000..6a77e8919d --- /dev/null +++ b/apps/cli/tests/built-bin.e2e.ts @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under + * plain Node (no tsx) with PIPED stdio and assert the TUI refuses to boot. + * `dsh` is the sole terminal front door; the TUI owns no non-TTY fallback, so a + * piped launch must exit nonzero with a stderr pointer at the one-shot `-p` + * mode. The guard fires inside `runTui` BEFORE the Loader resolves the config + * tree — a compose-time throw inside the tree is logged per-entry, not + * rethrown, so without this guard a piped launch would settle into an idle + * UI-less process. The bin resolves its workspace deps through the repo's + * node_modules, so no external consumer is assembled; missing-config fail-loud + * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's + * built-bin suite, and interactive TTY behavior is PTY-covered by + * examples/tui-agent. Skips before the bin is built. + */ + +const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') + +/** Run the built bin with PIPED stdio; resolve with output + exit code. */ +function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [dshBin], { stdio: ['pipe', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (c: string) => { stdout += c }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (c: string) => { stderr += c }) + const timer = setTimeout(() => { + child.kill('SIGKILL') + reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }, 25_000) + child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + child.on('error', (err) => { clearTimeout(timer); reject(err) }) + child.stdin.end() + }) +} + +describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { + it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => { + const { stdout, code, stderr } = await runBuiltBin() + expect(code).not.toBe(0) + expect(stderr).toContain('requires stdin and stdout to be interactive TTYs') + expect(stderr).toContain('dsh -p') + // The refusal happens before any plugin mounts: stdout stays silent. + expect(stdout).toBe('') + }, 30_000) +}) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 2e2221b6e8..e8ca907439 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -38,7 +38,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { setupFiles: ['./scripts/test-invariants.ts'], - include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate. From 007e8fd92f0b73734c68f4ae6f00c9edfa4089b3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:15:25 +0800 Subject: [PATCH 22/31] refactor(cli): bail early in the arg adapter instead of returning errors as data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review and cut ceremony: the adapter no longer models help/version/ errors as DshInvocation members. Commander owns those under exitOverride — it prints usage or the diagnostic and one try/catch in parseDshArgs turns the thrown CommanderError into process.exit with the intended code. bin.ts drops its help/version/error cases; the union is the three real modes. Domain checks bail via command.error(print + exit 1): --prompt rejects an empty task or a stray config/--resume, empty --resume= fails loud, and --host/--port are validated. A repeated --resume or a flag captured as a value is Commander's standard behavior, left alone (a bad id fails loud downstream). dsh --help discloses web via addHelpText. Net: args.ts 185 -> 112 lines. Also fixes review nits: built-bin e2e resolves on `close`; the /resume handoff uses `dsh --resume= -- ` so a config named `web` stays a positional; and stale prose (cordis.yml comment, app-boot module doc + duplicate JSDoc, ui/README, two feature notes, an agent-loop test name) tracks the shipped state. Removes tui-demo's now-dead plugin-include dep and vendor/loader + app-boot tsconfig references. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 8 +- ...07-24-dsh-commander-argument-adapter.zh.md | 8 +- ...21-dsh-system-prompt-source-path.i18n.yaml | 4 +- ...026-07-21-dsh-system-prompt-source-path.md | 2 +- ...-07-21-dsh-system-prompt-source-path.zh.md | 2 +- .../2026-07-21-tui-no-banner.i18n.yaml | 4 +- .../feature/2026-07-21-tui-no-banner.md | 2 +- .../feature/2026-07-21-tui-no-banner.zh.md | 2 +- apps/cli/src/args.ts | 147 ++++++------------ apps/cli/src/bin.ts | 11 +- apps/cli/src/headless.ts | 1 - apps/cli/src/tui.ts | 6 +- apps/cli/tests/args.spec.ts | 53 +++++-- apps/cli/tests/built-bin.e2e.ts | 3 +- examples/tui-agent/cordis.yml | 4 +- .../tests/config-session-id.spec.ts | 2 +- packages/examples/tui-demo/package.json | 2 - packages/examples/tui-demo/tsconfig.json | 6 - packages/ui/README.md | 2 +- packages/ui/app-boot/src/index.ts | 3 +- pnpm-lock.yaml | 5 +- 22 files changed, 116 insertions(+), 165 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 3a85dedb0d..3a70d276fe 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: c038f4facdc62039b8a31be5d660648fd81aebd2 -2026-07-24-dsh-commander-argument-adapter.zh.md: 285917af7c4b0da769ae7595bb1a6e5966adafd9 +2026-07-24-dsh-commander-argument-adapter.md: 0f6b18848eacc5d5771e500bf226cc6f680f8d3f +2026-07-24-dsh-commander-argument-adapter.zh.md: ae523d0e37d09ce1f85b317bb0850194045474cb diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index c038f4facd..0f6b18848e 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -10,15 +10,15 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di ## Decision -Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` resolves the invocation into a discriminated `DshInvocation` union: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, `{ mode: 'web', host, port, dev }`, `{ mode: 'help' | 'version', text }`, or `{ mode: 'error', message }`. Commander runs under `exitOverride()` with output captured, so it never writes or exits on its own — `--help`, `--version`, and every parse error come back as data. +Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host, port, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module. Each mode module now consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`) and `dsh -p x web` is just a headless prompt whose second positional is dropped, with no cross-command leakage to guard against. Each parser reads Commander's `opts()`/`processedArgs` after `parse()` rather than through action closures. `--host` is a `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])` and `--port` an `argParser` that range-checks 0–65535, moving both from the inline `runWeb` checks into the parser; `--dev` mounts the client HMR driver and bundle watch. Two post-parse checks preserve the "never silently start fresh" invariant: an empty `--resume=` id and an empty `-p` task each become a `mode: 'error'`, because agent-loop treats an empty resume id as no-resume and an empty prompt has nothing to run. A repeated `--resume` is Commander's natural last-wins (the old bespoke scanner rejected it; last-wins is the standard CLI behavior and needs no special case). `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535, moving both from the inline `runWeb` checks into the parser. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. ## Resume without an environment variable -Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a bin that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly as `dsh [config] --resume ` from the parsed values, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. +Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [-- ]` — the `--` keeps a config named `web` or starting with `-` a positional — so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. ## One terminal front door: `dsh` @@ -44,7 +44,7 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), the fail-loud checks (empty resume/prompt, bad host/port, unknown option), and `--help`/`--version` surfacing as data. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 285917af7c..ae523d0e37 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -10,15 +10,15 @@ Status: implemented ## 决策 -argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适配器(即 SDK bin,如 `create-sdk`、`dsh-scripts`,已经统一采用的那个解析器)。`parseDshArgs(argv, version)` 将调用解析为一个判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }`、`{ mode: 'web', host, port, dev }`、`{ mode: 'help' | 'version', text }` 或 `{ mode: 'error', message }`。Commander 在 `exitOverride()` 下运行并捕获输出,因此它自身从不写出或退出:`--help`、`--version` 和每个解析错误都以数据形式返回。 +argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host, port, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 调用一次适配器,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),只动态导入所选模式对应的模块。每个模式模块现在只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法——`dsh web -p x` 会显式报错(`web` 没有 `-p`),而 `dsh -p x web` 只是一个 headless prompt,其第二个位置参数被丢弃,无需防范任何跨命令泄漏。每个解析器都在 `parse()` 之后读取 Commander 的 `opts()`/`processedArgs`,而不是通过 action 闭包。`--host` 是一个 `.choices([LOOPBACK_HOST, ALL_INTERFACES_HOST])`,`--port` 是一个对 0–65535 做范围检查的 `argParser`,二者都从内联的 `runWeb` 检查移入了解析器;`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。两处解析后的检查保留了「绝不静默重新开始」不变式:空的 `--resume=` id 和空的 `-p` 任务各自变为 `mode: 'error'`,因为 agent-loop 把空的 resume id 视为不恢复,而空的 prompt 没有任何内容可运行。重复出现的 `--resume` 采用 Commander 天然的后者胜出(旧的定制扫描器会拒绝它;后者胜出是标准的 CLI 行为,无需特殊处理)。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法;`dsh web -p x` 会显式报错(`web` 没有 `-p`)。每个解析器都读取 Commander 的 `opts()`/`processedArgs`(在 `parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数,这两项校验都从 `runWeb` 的内联检查移入解析器。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 ## 无需环境变量即可恢复 -将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的 bin 也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh [config] --resume `,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 +将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [-- ]`;其中 `--` 可确保名称为 `web` 或以 `-` 开头的配置仍被视为位置参数。因此,合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 ## 唯一的终端入口:`dsh` @@ -44,7 +44,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,通过一个 Commander 适 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态选择模式(包括 `web --dev`)、显式报错场景(空的恢复会话 id、空提示词、非法主机、非法端口和未知选项),以及将 `--help` 和 `--version` 作为数据返回。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml index f1b9829b73..2c0b4d3404 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.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-21-dsh-system-prompt-source-path.md: b54d01488fd7c0b49e06200c93af2b056c9fd00b -2026-07-21-dsh-system-prompt-source-path.zh.md: 208e3dce072f63c280999e15276dce62ff4e5c43 +2026-07-21-dsh-system-prompt-source-path.md: 4cb89e8124840bba6633235d195e95957245137c +2026-07-21-dsh-system-prompt-source-path.zh.md: 90c23bed4a3f95155e323c63a68fe2da09543ea6 diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md index b54d01488f..4cb89e8124 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.md @@ -16,7 +16,7 @@ The testable logic lives in `dsh-app-boot`, not in `apps/cli`, because `apps/*` ## Scope -Only the `dsh` CLI adds this. The demo bins (`dsh-tui-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs. +Only the `dsh` CLI adds this. The demo bins (`dsh-cli-demo`, `dsh-acp-demo`) boot their committed trees verbatim and gain no source section: they are not the self-modification surface, and their checkout root is not a fact the model needs. ## HMR diff --git a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md index 208e3dce07..90c23bed4a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-dsh-system-prompt-source-path.zh.md @@ -16,7 +16,7 @@ Status: implemented ## Scope -只有 `dsh` CLI 会加入这一段。demo bin(`dsh-tui-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。 +只有 `dsh` CLI 会加入这一段。demo bin(`dsh-cli-demo`、`dsh-acp-demo`)原样引导它们已提交的插件树,不会获得 source 段:它们不是自我修改的接口,其检出根目录也不是模型需要知道的事实。 ## HMR diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml index 56333563f5..e5a2eb9f94 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.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-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1 -2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152 +2026-07-21-tui-no-banner.md: a6e0956f289cfc810da766fd0cae94b97baf5280 +2026-07-21-tui-no-banner.zh.md: acc5614727cf67881832af1685be557d675696e7 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md index f5f4b1b847..a6e0956f28 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md @@ -13,7 +13,7 @@ The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session d ## Decision - `HeaderComponent`, the sweep animation, and its lifecycle wiring are deleted. The TUI mounts straight into the transcript; startup renders nothing above the separator. -- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, and `RESUME_SESSION_ID` consumers retrieve it there. +- The model name moves into the footer status line's left segment (` ↑tokens ↓tokens`), so the session's driving model stays visible at all times, not just at boot. The session id is no longer displayed — it lives in the session log and `./.sessions` filenames, where `dsh --resume ` and the `/resume` selector retrieve it. - `welcome`, when configured, renders as the transcript's first line (a muted notice) inside `rebuildTranscript`, so palette swaps preserve it. Unset renders nothing. Fixtures keep their configured welcomes; the PTY smoke's boot marker becomes the footer's model name, the only mounted-TUI text guaranteed to render regardless of cwd length. This supersedes the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md) entirely: both the sweep and the banner it animated are gone. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md index 956fe03e2c..acc5614727 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md @@ -13,7 +13,7 @@ TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会 ## Decision - 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。 -- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。 +- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`dsh --resume ` 和 `/resume` 选择器会从中获取该 id。 - 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。 本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 37e87a1db7..8fc040168f 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,16 +1,14 @@ /** * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant - * and dynamic-imports that mode's module; each mode module then consumes the - * already-parsed values instead of re-reading argv. Output is suppressed and - * `exitOverride` is set so Commander never writes or exits on its own — every - * outcome (including `--help`/`--version` and parse errors) is returned to the - * caller as data. The `web` subcommand is a reserved first token dispatched to - * its own parser, so root flags and `web` flags never share a grammar. + * and dynamic-imports that mode's module. Commander owns `--help`/`--version` + * and parse errors: it prints and exits at the point of failure (a domain + * failure routes through `command.error`), so this returns only a resolved mode. + * The `web` subcommand is a reserved first token dispatched to its own parser. * @module @deepseek-ai/dsh/args */ -import { Command, CommanderError, InvalidArgumentError, Option } from 'commander' +import { Command, CommanderError } from 'commander' /** The loopback host `dsh web` binds by default. */ export const LOOPBACK_HOST = '127.0.0.1' @@ -31,10 +29,7 @@ interface HeadlessInvocation { prompt: string } -/** - * Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; - * port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch. - */ +/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 0–65535 integer, `dev` mounts the HMR driver. */ interface WebInvocation { mode: 'web' host: string @@ -42,120 +37,76 @@ interface WebInvocation { dev: boolean } -/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */ -interface InfoInvocation { - mode: 'help' | 'version' - text: string -} +/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ +export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation -/** A parse error (unknown option, missing/invalid argument): `bin.ts` prints `message` to stderr and exits 1. */ -interface ErrorInvocation { - mode: 'error' - message: string -} - -/** The resolved `dsh` invocation: exactly one mode, all values parsed and validated. */ -export type DshInvocation = - | TuiInvocation - | HeadlessInvocation - | WebInvocation - | InfoInvocation - | ErrorInvocation - -/** Coerce `--port` to an integer in 0–65535; a bad value fails loud as a parse error. */ -function parsePort(raw: string): number { - const port = Number(raw) - if (!Number.isInteger(port) || port < 0 || port > 65535) { - throw new InvalidArgumentError(`invalid --port ${raw}`) - } - return port -} - -/** - * A configured `Command` under `exitOverride` with output captured into `sink`, - * so `--help`, `--version`, and parse errors surface as thrown `CommanderError`s - * (see {@link settle}) rather than writing to a stream or exiting. - */ -function program(name: string, version: string, sink: string[]): Command { - return new Command() - .name(name) - .version(version, '-V, --version', 'output the version number') - .exitOverride() - .configureOutput({ - writeOut: chunk => void sink.push(chunk), - writeErr: chunk => void sink.push(chunk), - }) -} - -/** - * Run `command.parse` and map its thrown `CommanderError` to an info/error - * invocation, or `undefined` when the parse succeeded (the caller then reads the - * parsed options). - */ -function settle(command: Command, argv: readonly string[], sink: string[]): InfoInvocation | ErrorInvocation | undefined { - try { - command.parse(argv, { from: 'user' }) - return undefined - } catch (error) { - /* v8 ignore next -- Commander only throws CommanderError from parse under exitOverride */ - if (!(error instanceof CommanderError)) throw error - if (error.code === 'commander.helpDisplayed') return { mode: 'help', text: sink.join('') } - if (error.code === 'commander.version') return { mode: 'version', text: sink.join('') } - return { mode: 'error', message: error.message } - } +/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */ +function program(name: string, version: string): Command { + return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride() } /** Parse `dsh web` arguments (everything after the `web` token). */ -function parseWeb(argv: readonly string[], version: string): DshInvocation { - const sink: string[] = [] - const web = program('dsh web', version, sink) +function parseWeb(argv: readonly string[], version: string): WebInvocation { + const web = program('dsh web', version) .description('serve the browser UI') - .addOption(new Option('--host ', 'bind host').choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]).default(LOOPBACK_HOST)) - .addOption(new Option('--port ', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort)) + .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST) + .option('--port ', 'listen port', String(DEFAULT_WEB_PORT)) .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - const settled = settle(web, argv, sink) - if (settled !== undefined) return settled - const { host, port, dev } = web.opts<{ host: string; port: number; dev?: boolean }>() - return { mode: 'web', host, port, dev: dev ?? false } + web.parse(argv, { from: 'user' }) + const { host, port, dev } = web.opts<{ host: string; port: string; dev?: boolean }>() + if (host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { + web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) + } + const portNumber = Number(port) + if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { + web.error('error: --port must be an integer in 0-65535') + } + return { mode: 'web', host, port: portNumber, dev: dev === true } } /** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ function parseRoot(argv: readonly string[], version: string): DshInvocation { - const sink: string[] = [] - const root = program('dsh', version, sink) + const root = program('dsh', version) .description('dsh: interactive TUI, headless task, and browser UI') .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') .option('--resume ', 'resume the persisted session with this id (TUI mode)') - const settled = settle(root, argv, sink) - if (settled !== undefined) return settled + // Disclose the web mode in `dsh --help`; a real `web` subcommand would + // hijack the `[config]` positional. `parseDshArgs` intercepts `web` first. + .addHelpText('after', '\nCommands:\n web serve the browser UI (run `dsh web --help`)') + root.parse(argv, { from: 'user' }) const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>() const config = root.processedArgs[0] as string | undefined if (prompt !== undefined) { - // A headless prompt owns the invocation; an empty task has nothing to run. - if (prompt === '') return { mode: 'error', message: "error: option '-p, --prompt ' must not be empty" } + // A headless prompt owns the invocation; an empty task has nothing to run, + // and a config or --resume alongside it is a TUI input that must not + // silently vanish from the run. + if (prompt === '') root.error('error: --prompt needs a task') + if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume') return { mode: 'headless', prompt } } // An empty `--resume=` id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. - if (resume === '') return { mode: 'error', message: "error: option '--resume ' must not be empty" } - return { - mode: 'tui', - ...config !== undefined ? { config } : {}, - ...resume !== undefined ? { resume } : {}, - } + if (resume === '') root.error('error: --resume needs a session id') + return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } } } /** - * Resolve the raw argv into a single {@link DshInvocation}. Never writes to a - * stream and never exits; `--help`/`--version` and every parse error come back - * as data for `bin.ts` to act on. A leading `web` token dispatches to the web - * parser; everything else is the default TUI/headless grammar. + * Resolve the raw argv into a {@link DshInvocation}, or print and exit for + * `--help`/`--version`/a parse error. A leading `web` token dispatches to the + * web parser; everything else is the default TUI/headless grammar. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param version - the version string `--version` prints; read from this app's package.json. - * @returns the resolved invocation, discriminated by `mode`. + * @returns the resolved invocation (only reached on a valid, non-help invocation). */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { - return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) + try { + return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) + } catch (error) { + // Commander printed help/version/the error under `exitOverride`; exit with + // the code it chose (0 for help/version, 1 for a parse or domain error). + /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */ + return process.exit(error instanceof CommanderError ? error.exitCode : 1) + } } diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 3e5f38a859..207064eb89 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -3,8 +3,8 @@ * dsh — command-line entry. Parses argv once through the Commander adapter and * switches on the resolved mode; dynamic imports keep unrelated modes out of * each dispatch path. `web` and headless prompts run their own module; - * everything else opens the TUI. `--help`/`--version` print and exit 0; a parse - * error prints to stderr and exits 1. + * everything else opens the TUI. The adapter itself prints and exits for + * `--help`/`--version`/a parse error, so only a valid mode reaches the switch. * @module @deepseek-ai/dsh/bin */ @@ -45,13 +45,6 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume) break } - case 'help': - case 'version': - process.stdout.write(invocation.text) - process.exit(0) - case 'error': - process.stderr.write(`${invocation.message}\n`) - process.exit(1) default: invocation satisfies never throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index ccfd4c5f8a..50fe0390c8 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -71,7 +71,6 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, * @param task - the prompt text for the single turn. */ export async function runHeadless(task: string): Promise { - // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const host = await startHost({ boot: { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6ddad89302..e741306463 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -74,13 +74,13 @@ export async function runTui(config: string | undefined, resumeSessionId: string if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) // Rebuild argv from the parsed config plus the selected id: TUI mode's // only arguments are the optional config positional and `--resume `. + // The `--` guard keeps a config named like a flag or `web` a positional. const nextArgv = [ process.execPath, ...process.execArgv, entry, - ...config !== undefined ? [config] : [], - '--resume', - sessionId, + `--resume=${sessionId}`, + ...config !== undefined ? ['--', config] : [], ] try { await current.fiber.dispose() diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index f207a04f43..80e64534c5 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,8 +1,28 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') +/** + * `parseDshArgs` calls `process.exit` for `--help`/`--version`/errors and lets + * Commander print to the real streams; capture the exit code and mute output. + */ +function exitCode(argv: string[]): number { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit') }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + vi.spyOn(process.stderr, 'write').mockReturnValue(true) + try { + parse(argv) + throw new Error(`expected ${JSON.stringify(argv)} to exit`) + } catch { + return exit.mock.calls.at(-1)?.[0] as number + } finally { + vi.restoreAllMocks() + } +} + +afterEach(() => { vi.restoreAllMocks() }) + describe('parseDshArgs', () => { it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) @@ -10,25 +30,24 @@ describe('parseDshArgs', () => { expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false }) - expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080'])) - .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: false }) - expect(parse(['web', '--dev'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: true }) + expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true }) }) - it('fails loud instead of silently starting fresh or serving on bad input', () => { - // An empty resume/prompt would otherwise be swallowed (agent-loop treats an - // empty resume id as no-resume); a bad host/port must not reach the listener. - expect(parse(['--resume=']).mode).toBe('error') - expect(parse(['-p', '']).mode).toBe('error') - expect(parse(['web', '--host', '10.0.0.1']).mode).toBe('error') - expect(parse(['web', '--port', 'abc']).mode).toBe('error') - expect(parse(['--bogus']).mode).toBe('error') + it('exits nonzero instead of silently starting fresh, serving, or dropping inputs', () => { + // Empty resume/prompt would be swallowed downstream; bad host/port must not + // reach the listener; --prompt mixed with TUI inputs must not lose them. + expect(exitCode(['--resume='])).toBe(1) + expect(exitCode(['-p', ''])).toBe(1) + expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1) + expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['web', '--port='])).toBe(1) + expect(exitCode(['config.yml', '-p', 'x'])).toBe(1) + expect(exitCode(['--bogus'])).toBe(1) }) - it('surfaces --help and --version as printable data, not a process exit', () => { - const help = parse(['--help']) - expect(help).toMatchObject({ mode: 'help' }) - if (help.mode === 'help') expect(help.text).toContain('Usage: dsh') - expect(parse(['--version'])).toEqual({ mode: 'version', text: '1.2.3\n' }) + it('exits 0 for --help (disclosing web) and --version', () => { + expect(exitCode(['--help'])).toBe(0) + expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 6a77e8919d..9fd1d55ab2 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -36,7 +36,8 @@ function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string } child.kill('SIGKILL') reject(new Error(`dsh built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`)) }, 25_000) - child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) + // Resolve on `close` (all stdio drained), not `exit`, so captured output is complete. + child.on('close', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) }) child.on('error', (err) => { clearTimeout(timer); reject(err) }) child.stdin.end() }) diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index af225565e9..e96ef680b2 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -34,8 +34,8 @@ model: deepseek-v4-pro # `dsh --resume ` provides the session id on the boot context (the ids # live under ./.sessions); with no flag the identifier is undefined and a - # fresh session starts each run. The demo bin never provides it, so the - # typeof guard reads undefined there rather than throwing. + # fresh session starts each run. The typeof guard tolerates a launcher that + # never provides the slot, reading undefined rather than throwing. resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' # Printed on exit and listed by `/resume`; `{session}` fills the live id. diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index d144127498..0b6ad2b2ec 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -359,7 +359,7 @@ describe('config-driven session id', () => { await ctx2.fiber.dispose() }) - it('config-driven resumeSessionId continues a persisted session (env-var resume)', async () => { + it('config-driven resumeSessionId continues a persisted session', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-')) dirs.push(root) diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 26e3e64183..50145e6c29 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -27,7 +27,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", @@ -51,7 +50,6 @@ "schemastery": "^3.17.0" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d87f0f1c9e..d26d5b7da6 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -14,12 +14,6 @@ { "path": "../../../vendor/schemastery" }, - { - "path": "../../../vendor/loader" - }, - { - "path": "../../ui/app-boot" - }, { "path": "../../core/agent" }, diff --git a/packages/ui/README.md b/packages/ui/README.md index f8e4704f20..772b7a9d57 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -18,4 +18,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -The runnable app bundles that bake these bridges into boot bins — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles that compose these bridges — the TUI app, ACP server app, and JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 00e316ebe8..df2d2b1ba4 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored + * Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader * against a leaf `cordis.yml` until the whole tree has settled. @@ -156,7 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** /** * Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume * session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cccc15282..e0153978b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1539,9 +1539,6 @@ importers: packages/examples/tui-demo: devDependencies: - '@cordisjs/plugin-include': - specifier: workspace:^ - version: link:../../../vendor/include '@cordisjs/plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader @@ -1604,7 +1601,7 @@ importers: version: link:../../context/workspace-context cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) schemastery: specifier: ^3.17.0 version: 3.18.0 From 91d86f9b210854d3a95ca6c33834bb1154695361 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:03:17 +0800 Subject: [PATCH 23/31] fix(cli): let cordis.yml own the web host/port default (single source) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge's "always pass adapter-resolved host/port to AppCLIEntry" made the adapter's 127.0.0.1/3080 shadow apps/cli/cordis.yml's webserver row — editing the yml port would have had no effect, a duplicated default. The adapter now assigns no host/port default: an absent --host/--port leaves the field undefined (WebInvocation.host?/port?), runWeb forwards each to AppCLIEntry only when present, and AppCLIEntry patches the webserver row only for an explicit flag. cordis.yml is the single source of the host/port default; the adapter still validates a flag when given. Removes the now-unused DEFAULT_WEB_PORT; LOOPBACK_HOST/ALL_INTERFACES_HOST stay as the allowed-value vocabulary (validation + the printed URL/LAN line). --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 4 +- ...07-24-dsh-commander-argument-adapter.zh.md | 4 +- apps/cli/src/args.ts | 40 +++++++++++++------ apps/cli/src/web.ts | 18 ++++++--- apps/cli/tests/args.spec.ts | 5 ++- 6 files changed, 48 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 3a70d276fe..7e947bbed6 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: 0f6b18848eacc5d5771e500bf226cc6f680f8d3f -2026-07-24-dsh-commander-argument-adapter.zh.md: ae523d0e37d09ce1f85b317bb0850194045474cb +2026-07-24-dsh-commander-argument-adapter.md: f90c4fb8d428eabed353176d98dce0fb9e34bf99 +2026-07-24-dsh-commander-argument-adapter.zh.md: fc0d1aa588ca6ce3b8c9d0b59343c4af698103da diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index 0f6b18848e..f90c4fb8d4 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -10,9 +10,9 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di ## Decision -Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host, port, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. +Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535, moving both from the inline `runWeb` checks into the parser. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index ae523d0e37..fc0d1aa588 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -10,9 +10,9 @@ Status: implemented ## 决策 -argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host, port, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 +argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法;`dsh web -p x` 会显式报错(`web` 没有 `-p`)。每个解析器都读取 Commander 的 `opts()`/`processedArgs`(在 `parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数,这两项校验都从 `runWeb` 的内联检查移入解析器。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法;`dsh web -p x` 会显式报错(`web` 没有 `-p`)。每个解析器都读取 Commander 的 `opts()`/`processedArgs`(在 `parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 8fc040168f..ff0cc65c84 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -14,7 +14,6 @@ import { Command, CommanderError } from 'commander' export const LOOPBACK_HOST = '127.0.0.1' /** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */ export const ALL_INTERFACES_HOST = '0.0.0.0' -const DEFAULT_WEB_PORT = 3080 /** Interactive TUI: the default mode. Optional positional config and `--resume `. */ interface TuiInvocation { @@ -29,11 +28,16 @@ interface HeadlessInvocation { prompt: string } -/** Browser UI: `dsh web`. Host is loopback/all-interfaces, port a 0–65535 integer, `dev` mounts the HMR driver. */ +/** + * Browser UI: `dsh web`. `host`/`port` are present only when the flag was + * passed (validated: host is loopback/all-interfaces, port a 0–65535 integer); + * absent means the shipped `cordis.yml` default stands, so the yml is the sole + * source of the default. `dev` mounts the client HMR driver. + */ interface WebInvocation { mode: 'web' - host: string - port: number + host?: string + port?: number dev: boolean } @@ -47,21 +51,31 @@ function program(name: string, version: string): Command { /** Parse `dsh web` arguments (everything after the `web` token). */ function parseWeb(argv: readonly string[], version: string): WebInvocation { + // No Commander `default`: an absent flag leaves the option undefined so the + // shipped cordis.yml value stands (the single source of the host/port default). const web = program('dsh web', version) - .description('serve the browser UI') - .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`, LOOPBACK_HOST) - .option('--port ', 'listen port', String(DEFAULT_WEB_PORT)) + .description('serve the browser UI (host/port default to the shipped config)') + .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) + .option('--port ', 'listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') web.parse(argv, { from: 'user' }) - const { host, port, dev } = web.opts<{ host: string; port: string; dev?: boolean }>() - if (host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { + const { host, port, dev } = web.opts<{ host?: string; port?: string; dev?: boolean }>() + if (host !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) } - const portNumber = Number(port) - if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { - web.error('error: --port must be an integer in 0-65535') + let portNumber: number | undefined + if (port !== undefined) { + portNumber = Number(port) + if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { + web.error('error: --port must be an integer in 0-65535') + } + } + return { + mode: 'web', + ...host !== undefined && { host }, + ...portNumber !== undefined && { port: portNumber }, + dev: dev === true, } - return { mode: 'web', host, port: portNumber, dev: dev === true } } /** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index c8ecf581ba..1f32c74d0d 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -13,13 +13,19 @@ import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) /** - * Serve the browser UI from the shipped config tree. - * @param hostAddress - the bind host: {@link LOOPBACK_HOST} or {@link ALL_INTERFACES_HOST}. - * @param port - the listen port; `0` lets the OS choose a free port. + * Serve the browser UI from the shipped config tree. `host`/`port` are passed + * through only when the flag was given; absent, the `cordis.yml` value stands. + * @param host - the bind host ({@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}), or `undefined` to keep the config default. + * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. */ -export async function runWeb(hostAddress: string, port: number, dev: boolean): Promise { - const entry = new AppCLIEntry({ configPath: CONFIG_PATH, dev, host: hostAddress, port }) +export async function runWeb(host: string | undefined, port: number | undefined, dev: boolean): Promise { + const entry = new AppCLIEntry({ + configPath: CONFIG_PATH, + dev, + ...host !== undefined && { host }, + ...port !== undefined && { port }, + }) const { ctx, port: boundPort } = await entry.run() let exiting = false @@ -29,7 +35,7 @@ export async function runWeb(hostAddress: string, port: number, dev: boolean): P void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) }) } - const lanCandidate = hostAddress === ALL_INTERFACES_HOST + const lanCandidate = host === ALL_INTERFACES_HOST ? Object.values(networkInterfaces()).flat() .find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal) : undefined diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 80e64534c5..f9f6363660 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ALL_INTERFACES_HOST, LOOPBACK_HOST, parseDshArgs } from '../src/args.ts' +import { ALL_INTERFACES_HOST, parseDshArgs } from '../src/args.ts' const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') @@ -29,7 +29,8 @@ describe('parseDshArgs', () => { expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false }) + // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev'])) .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true }) }) From 3da324d1e2aee5b8b04619cec44004eaf7acb4c4 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 25 Jul 2026 15:38:09 +0800 Subject: [PATCH 24/31] 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 fca2dda37ddc5ba2c4317138e95f5d39da44d68f Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:47:55 +0800 Subject: [PATCH 25/31] =?UTF-8?q?refactor(cli):=20unify=20the=20arg=20gram?= =?UTF-8?q?mar=20=E2=80=94=20one=20program,=20--config=20flag,=20real=20we?= =?UTF-8?q?b=20subcommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the bare `dsh ` positional in favor of a `--config ` flag. Without a root positional, `web` can be a real Commander subcommand in one program instead of the reserved-first-token dispatch to a second parser, so `dsh --help` lists every mode natively (no hand-pasted command text) and the second parser + reserved-token machinery are gone. Grammar: dsh TUI (shipped tree + ~/.dsh overlay) dsh --config TUI, alternate tree (demos/tests only) dsh --resume TUI, resume a session dsh -p "task" headless one-shot dsh web [--host --port --dev] `dsh` is the product front door with no positional; `--config` exists only so demo:cordis, demo:code-mode, and the keyless PTY smokes can point the shipped bin at an example tree. Those three sites and the /resume re-exec argv move to `--config `. The `-p` + `--config`/`--resume` mode-mixing guard and the cordis.yml-owns-host/port-default fix are preserved. Agent Note + Chinese pair, README, tui.ts docs updated. All 13 PTY smokes (including code-mode via --config and the exec-replace resume handoff) green. --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 14 +- ...07-24-dsh-commander-argument-adapter.zh.md | 14 +- apps/cli/README.md | 6 +- apps/cli/src/args.ts | 129 ++++++++++-------- apps/cli/src/tui.ts | 9 +- apps/cli/tests/args.spec.ts | 8 +- docs/module-graph.md | 3 +- examples/tui-agent/tests/pty-harness.ts | 4 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 4 +- package.json | 2 +- scripts/demo-code-mode.mjs | 2 +- vitest.e2e.config.ts | 4 +- 13 files changed, 110 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 7e947bbed6..6ac3cfdf1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: f90c4fb8d428eabed353176d98dce0fb9e34bf99 -2026-07-24-dsh-commander-argument-adapter.zh.md: fc0d1aa588ca6ce3b8c9d0b59343c4af698103da +2026-07-24-dsh-commander-argument-adapter.md: e023d9ff296dd4a4024824865358964c8a66f49a +2026-07-24-dsh-commander-argument-adapter.zh.md: 762e3e4b1609e9bc6f9f5bd5cc509c4084573833 diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index f90c4fb8d4..e023d9ff29 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -12,17 +12,19 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. `web` is a **reserved first token**: `parseDshArgs` dispatches a leading `web` to its own Commander parser and everything else to the default TUI/headless parser, so root flags and `web` flags never share a grammar — `dsh web -p x` fails loud (`web` has no `-p`). Each parser reads Commander's `opts()`/`processedArgs` after `parse()`, then bails via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a stray config/`--resume` rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `dsh --help` discloses the `web` mode through an `addHelpText` line (a real `web` subcommand would hijack the `[config]` positional). `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. + +`--config ` replaces an earlier positional config argument. `dsh` is the product front door with no positional; the flag exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an alternate example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. `parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. ## Resume without an environment variable -Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [-- ]` — the `--` keeps a config named `web` or starting with `-` a positional — so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. +Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [--config ]`, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. ## One terminal front door: `dsh` -The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh [config]` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with the config as the positional argument, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. +The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh --config ` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with `--config `, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. ## Package topology @@ -34,17 +36,17 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages **Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. -**Make `web` a Commander subcommand of one root program** — rejected: a single program mixing a root `-p`/`--resume` grammar with a `web` subcommand leaks the root options onto `web` unless `enablePositionalOptions()` plus a parent-option guard are bolted on, which is exactly the kind of special-case machinery this change removes. Dispatching `web` as a reserved first token to a second parser is smaller and keeps the two grammars fully independent. +**Keep the bare `dsh ` positional (and the reserved-`web`-token dispatch it forced)** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional), which is why an earlier revision dispatched a reserved leading `web` token to a second parser and hand-pasted a `web` line into `--help`. The positional existed only so the demo/test sites could boot an alternate tree through the shipped bin. Replacing it with a `--config` flag frees the default surface of any positional, so `web` becomes a normal subcommand in one program with native `--help` — deleting the reserved-token dispatch, the second parser, and the pasted help text. `dsh` loses nothing a user wanted; the demos gain an explicit flag. **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. **Keep `RESUME_SESSION_ID` as the resume bridge** — rejected: with `--resume` parsed into a value the bin already holds, threading it through an environment variable the config re-reads is indirection with no benefit, and it left the demo bin a second, env-only resume path. Providing the id on the boot context is the same channel `boot`'s `prepare` hook already uses for `tuiResumeHost`. -**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh [config]` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. +**Keep the `dsh-tui-demo` bin** — rejected: it duplicated `dsh --config ` exactly, and keeping it forced the demo-only `RESUME_SESSION_ID` fallback to stay alive. Its plugin is what the configs actually mount; only the front-door bin was redundant, and `dsh` is the one terminal entry point. ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots the config as a positional, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index fc0d1aa588..762e3e4b16 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -12,17 +12,19 @@ Status: implemented argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。`web` 是一个**保留的首个 token**:`parseDshArgs` 将开头的 `web` 分发给它自己的 Commander 解析器,其余一切分发给默认的 TUI/headless 解析器,因此根级标志与 `web` 标志从不共用同一套语法;`dsh web -p x` 会显式报错(`web` 没有 `-p`)。每个解析器都读取 Commander 的 `opts()`/`processedArgs`(在 `parse()` 之后),随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式,并在任务为空或存在多余的配置位置参数或 `--resume` 时拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`dsh --help` 会展示 `web` 模式,具体通过一行 `addHelpText` 文本实现(真正的 `web` 子命令会劫持 `[config]` 位置参数)。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 + +`--config ` 取代了先前的配置位置参数。`dsh` 是不接受位置参数的产品入口;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动另一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 `parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 ## 无需环境变量即可恢复 -将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [-- ]`;其中 `--` 可确保名称为 `web` 或以 `-` 开头的配置仍被视为位置参数。因此,合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 +将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [--config ]`,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh [config]` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并将配置作为位置参数;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 +`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh --config ` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 ## 包拓扑 @@ -34,17 +36,17 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 **保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 -**把 `web` 做成单个根程序的 Commander 子命令。** 已否决:一个程序若把根级 `-p`/`--resume` 语法与 `web` 子命令混在一起,除非再加上 `enablePositionalOptions()` 和一个父级选项守卫,否则根级选项会泄漏到 `web` 上——而这正是这次变更要移除的那类特殊处理机制。把 `web` 作为保留的首个 token 分发给第二个解析器更小巧,且让两套语法完全独立。 +**保留裸 `dsh ` 位置参数(以及它迫使系统采用的保留 `web` token 分发机制)。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。因此,先前版本才会把开头保留的 `web` token 分发给第二个解析器,并在 `--help` 中手工拼接一行 `web` 文本。该位置参数仅用于让演示和测试调用点通过随产品提供的 bin 启动另一份示例树。将其替换为 `--config` 标志后,默认接口不再包含任何位置参数,`web` 因而成为单个程序中的普通子命令,并由原生 `--help` 展示;保留 token 分发、第二个解析器和手工拼接的帮助文本均被删除。`dsh` 没有损失任何用户所需的功能,演示调用则改用显式标志。 **把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 **保留 `RESUME_SESSION_ID` 作为恢复通道**:不予采纳。`--resume` 已被解析成 bin 当前持有的值;若再通过环境变量传递并由配置重新读取,只会引入无益的间接层,还会使演示 bin 保留第二条仅依赖环境变量的恢复路径。在启动上下文中提供 id,与 `boot` 的 `prepare` 钩子为 `tuiResumeHost` 提供值所采用的是同一通道。 -**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh [config]` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 +**保留 `dsh-tui-demo` bin**:不予采纳。它与 `dsh --config ` 的功能完全重复;保留它还会迫使演示专用的 `RESUME_SESSION_ID` 回退路径继续存在。配置实际挂载的是该包的插件;冗余的只有作为终端入口的 bin,而 `dsh` 是唯一的终端入口。 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组将配置作为位置参数启动,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 diff --git a/apps/cli/README.md b/apps/cli/README.md index 9e8c9b1e45..1241154d31 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,12 +1,12 @@ # `@deepseek-ai/dsh` -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh [config.yml]` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)) that resolves the invocation into a single mode; `src/bin.ts` switches on that mode and dynamic-imports only the chosen mode's module. `dsh --help` and `dsh web --help` render usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ff0cc65c84..8c804eddb5 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,10 +1,11 @@ /** * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant - * and dynamic-imports that mode's module. Commander owns `--help`/`--version` - * and parse errors: it prints and exits at the point of failure (a domain - * failure routes through `command.error`), so this returns only a resolved mode. - * The `web` subcommand is a reserved first token dispatched to its own parser. + * and dynamic-imports that mode's module. One program: the default (no + * subcommand) is the TUI/headless surface with option-only flags; `web` is a + * real subcommand. Commander owns `--help`/`--version` and parse errors — it + * prints and exits at the point of failure (a domain failure routes through + * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ @@ -15,7 +16,7 @@ export const LOOPBACK_HOST = '127.0.0.1' /** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */ export const ALL_INTERFACES_HOST = '0.0.0.0' -/** Interactive TUI: the default mode. Optional positional config and `--resume `. */ +/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' config?: string @@ -44,83 +45,91 @@ interface WebInvocation { /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation -/** A `Command` under `exitOverride`, so {@link parseDshArgs} owns the exit, named for its usage line. */ -function program(name: string, version: string): Command { - return new Command().name(name).version(version, '-V, --version', 'output the version number').exitOverride() +/** Raw web-subcommand options before validation. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean } -/** Parse `dsh web` arguments (everything after the `web` token). */ -function parseWeb(argv: readonly string[], version: string): WebInvocation { - // No Commander `default`: an absent flag leaves the option undefined so the - // shipped cordis.yml value stands (the single source of the host/port default). - const web = program('dsh web', version) - .description('serve the browser UI (host/port default to the shipped config)') - .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) - .option('--port ', 'listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - web.parse(argv, { from: 'user' }) - const { host, port, dev } = web.opts<{ host?: string; port?: string; dev?: boolean }>() - if (host !== undefined && host !== LOOPBACK_HOST && host !== ALL_INTERFACES_HOST) { - web.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) +/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */ +function resolveWeb(command: Command, options: WebOptions): WebInvocation { + if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) { + command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) } - let portNumber: number | undefined - if (port !== undefined) { - portNumber = Number(port) - if (!/^\d+$/.test(port) || !Number.isInteger(portNumber) || portNumber > 65535) { - web.error('error: --port must be an integer in 0-65535') + let port: number | undefined + if (options.port !== undefined) { + port = Number(options.port) + if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) { + command.error('error: --port must be an integer in 0-65535') } } return { mode: 'web', - ...host !== undefined && { host }, - ...portNumber !== undefined && { port: portNumber }, - dev: dev === true, + ...options.host !== undefined && { host: options.host }, + ...port !== undefined && { port }, + dev: options.dev === true, } } -/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */ -function parseRoot(argv: readonly string[], version: string): DshInvocation { - const root = program('dsh', version) - .description('dsh: interactive TUI, headless task, and browser UI') - .argument('[config]', 'config to boot instead of the shipped default (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - // Disclose the web mode in `dsh --help`; a real `web` subcommand would - // hijack the `[config]` positional. `parseDshArgs` intercepts `web` first. - .addHelpText('after', '\nCommands:\n web serve the browser UI (run `dsh web --help`)') - root.parse(argv, { from: 'user' }) - const { prompt, resume } = root.opts<{ prompt?: string; resume?: string }>() - const config = root.processedArgs[0] as string | undefined - - if (prompt !== undefined) { - // A headless prompt owns the invocation; an empty task has nothing to run, - // and a config or --resume alongside it is a TUI input that must not - // silently vanish from the run. - if (prompt === '') root.error('error: --prompt needs a task') - if (config !== undefined || resume !== undefined) root.error('error: --prompt takes no config or --resume') - return { mode: 'headless', prompt } - } - // An empty `--resume=` id would silently start a fresh session downstream - // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. - if (resume === '') root.error('error: --resume needs a session id') - return { mode: 'tui', ...config !== undefined && { config }, ...resume !== undefined && { resume } } -} - /** * Resolve the raw argv into a {@link DshInvocation}, or print and exit for - * `--help`/`--version`/a parse error. A leading `web` token dispatches to the - * web parser; everything else is the default TUI/headless grammar. + * `--help`/`--version`/a parse error. The default (no subcommand) is the + * TUI/headless surface; `web` is a subcommand. * @param argv - the arguments after the node binary and script (`process.argv.slice(2)`). * @param version - the version string `--version` prints; read from this app's package.json. * @returns the resolved invocation (only reached on a valid, non-help invocation). */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { + let resolved: DshInvocation | undefined + const program = new Command() + .name('dsh') + .version(version, '-V, --version', 'output the version number') + .description('dsh: interactive TUI (default), headless task, and browser UI') + .exitOverride() + // Default surface: option-only (no positional), so `web` can be a real + // subcommand without a positional collision. + .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .action((options: { config?: string; prompt?: string; resume?: string }) => { + if (options.prompt !== undefined) { + // A headless prompt owns the invocation; an empty task has nothing to + // run, and --config/--resume are TUI inputs that must not silently + // vanish from a headless run. + if (options.prompt === '') program.error('error: --prompt needs a task') + if (options.config !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config or --resume') + } + resolved = { mode: 'headless', prompt: options.prompt } + return + } + // An empty --resume= id would silently start a fresh session downstream + // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. + if (options.resume === '') program.error('error: --resume needs a session id') + resolved = { + mode: 'tui', + ...options.config !== undefined && { config: options.config }, + ...options.resume !== undefined && { resume: options.resume }, + } + }) + + const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + web + .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) + .option('--port ', 'listen port (0 requests an OS-assigned port)') + .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .action((options: WebOptions) => { resolved = resolveWeb(web, options) }) + try { - return argv[0] === 'web' ? parseWeb(argv.slice(1), version) : parseRoot(argv, version) + program.parse(argv, { from: 'user' }) } catch (error) { // Commander printed help/version/the error under `exitOverride`; exit with // the code it chose (0 for help/version, 1 for a parse or domain error). /* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */ return process.exit(error instanceof CommanderError ? error.exitCode : 1) } + /* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */ + if (resolved === undefined) throw new Error('dsh: no invocation resolved') + return resolved } diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e741306463..4283668189 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,6 +1,6 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or an explicit config argument) with the personal overlay + * tui-agent config (or the `--config` override) with the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking @@ -42,7 +42,7 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) /** * Run the interactive TUI from the invoking directory. * @param config - a config path to boot instead of the shipped default, or - * `undefined` for the default; already parsed from the optional positional. + * `undefined` for the default; already parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined`; * already parsed and non-empty-validated from `--resume`. It is provided on the * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config @@ -73,14 +73,13 @@ export async function runTui(config: string | undefined, resumeSessionId: string const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are the optional config positional and `--resume `. - // The `--` guard keeps a config named like a flag or `web` a positional. + // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, `--resume=${sessionId}`, - ...config !== undefined ? ['--', config] : [], + ...config !== undefined ? ['--config', config] : [], ] try { await current.fiber.dispose() diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index f9f6363660..a0943d5e66 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -26,8 +26,8 @@ afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) - expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) - expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) + expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) @@ -43,8 +43,10 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1) expect(exitCode(['web', '--port', 'abc'])).toBe(1) expect(exitCode(['web', '--port='])).toBe(1) - expect(exitCode(['config.yml', '-p', 'x'])).toBe(1) + expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) + expect(exitCode(['bogus-positional'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/docs/module-graph.md b/docs/module-graph.md index d5845bf041..5de50ff672 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -765,7 +765,6 @@ flowchart TD pkg_tui_demo --> pkg_agent pkg_tui_demo --> pkg_agent_loop pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_app_boot pkg_tui_demo --> pkg_command_goal pkg_tui_demo --> pkg_commands pkg_tui_demo --> pkg_invariants @@ -919,4 +918,4 @@ flowchart TD | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | diff --git a/examples/tui-agent/tests/pty-harness.ts b/examples/tui-agent/tests/pty-harness.ts index e55e77f4de..700c67f660 100644 --- a/examples/tui-agent/tests/pty-harness.ts +++ b/examples/tui-agent/tests/pty-harness.ts @@ -192,10 +192,12 @@ export async function runTuiPtySmoke(options: TuiPtySmokeOptions): Promise` tree override; `configArgs` + // is the raw-args escape (e.g. `['--resume', ]`) for other flags. configArgs: options.configArgs !== undefined ? [...options.configArgs] /* v8 ignore next -- every caller passes configPath or configArgs; the fallback keeps the type total */ - : [options.configPath ?? './cordis.yml'], + : options.configPath !== undefined ? ['--config', options.configPath] : [], tsconfigPath: options.tsconfigPath, env: { DSH_HOME: join(cwd, '.dsh'), diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index c464fa2a96..348ac94751 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -253,7 +253,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh in-place resume', tempDirPrefix: 'dsh-in-place-resume-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, prepare: seedResumeSession, actions: [ { waitFor: 'scripted TUI ready.', send: '/resume\r' }, @@ -350,7 +350,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { label: 'dsh source-path prompt', tempDirPrefix: 'dsh-source-path-', binScript: dshBinScript, - configArgs: [scriptedConfigPath], + configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, { waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', send: 'exercise the TUI\r' }, diff --git a/package.json b/package.json index fbd2a8aa88..543ebea5e6 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "demo:headless": "node --import tsx packages/examples/cli-demo/src/bin.ts --config examples/headless-agent/cordis.yml", "demo:tui": "node --import tsx apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node --import tsx apps/cli/src/bin.ts examples/cordis-agent/cordis.yml", + "demo:cordis": "node --import tsx apps/cli/src/bin.ts --config examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "demo:web": "npm run build && npm run build:web && node --import tsx apps/cli/src/bin.ts web", "dev:web": "tsx scripts/dev-web.ts --poll", diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 7b06b859f2..1118f10b96 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -7,7 +7,7 @@ import { spawn } from 'node:child_process' // Each UI's node invocation matches its base demo script plus the overlay config. const UIS = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], + ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index e8ca907439..3f9ceada28 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -38,7 +38,9 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] })], test: { setupFiles: ['./scripts/test-invariants.ts'], - include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], + // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built + // frontend dist and runs under vitest.web.config.ts (the test:web job). + include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes // (the shared internal key hits concurrency quotas). No coverage — the // unit suites own the coverage gate. From 6a8049879edbddb950c7f0fc0cc13fd6ace11153 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:50:10 +0800 Subject: [PATCH 26/31] docs(cli): trim bin.ts module comment to the non-obvious contract Review (turtle1999): the opening narrated control flow. Drop the argv-parse/ switch narration; keep only the two non-obvious facts (per-mode dynamic imports, and that the adapter exits so only a valid mode reaches the switch). --- apps/cli/src/bin.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 207064eb89..5e92c18d9d 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -1,9 +1,7 @@ #!/usr/bin/env node /** - * dsh — command-line entry. Parses argv once through the Commander adapter and - * switches on the resolved mode; dynamic imports keep unrelated modes out of - * each dispatch path. `web` and headless prompts run their own module; - * everything else opens the TUI. The adapter itself prints and exits for + * dsh — command-line entry. Dynamic imports per mode keep unrelated modes out + * of each dispatch path; the adapter prints and exits for * `--help`/`--version`/a parse error, so only a valid mode reaches the switch. * @module @deepseek-ai/dsh/bin */ From 9f6dbde7f6b401bc5ab6ad2de06ee5eaf6647cda Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 16:19:02 +0800 Subject: [PATCH 27/31] refactor(cli): let the webserver schema own web --host/--port validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter no longer validates --host/--port or declares the allowed set: LOOPBACK_HOST/ALL_INTERFACES_HOST leave args.ts. --host/--port are now unvalidated pass-through overrides — the adapter only Number-coerces the port string (the dsh-host-webserver schema wants a number). That schema (host a 127.0.0.1/0.0.0.0 literal union, port a natural <= 65535) is the single source of both the default (the shipped cordis.yml webserver row) and validity; AppCLIEntry patches an explicit flag into that row, so a bad host/port fails loud at the schema on boot (verified: `dsh web --host 9.9.9.9` and `--port abc` both exit 1 with the schema's ValidationError). web.ts keeps two display-only literals (the printed loopback URL, the all-interfaces LAN-detection check), commented as mirrors of the schema, not a source of truth. Agent Note + Chinese pair and README updated; the args spec drops the host/port exit-code cases (now the schema's job, covered by the web smoke on boot). --- ...4-dsh-commander-argument-adapter.i18n.yaml | 4 +- ...26-07-24-dsh-commander-argument-adapter.md | 4 +- ...07-24-dsh-commander-argument-adapter.zh.md | 4 +- apps/cli/README.md | 2 +- apps/cli/src/args.ts | 43 ++++++++----------- apps/cli/src/web.ts | 14 ++++-- apps/cli/tests/args.spec.ts | 18 ++++---- 7 files changed, 44 insertions(+), 45 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 6ac3cfdf1a..d3e2cb30f7 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: e023d9ff296dd4a4024824865358964c8a66f49a -2026-07-24-dsh-commander-argument-adapter.zh.md: 762e3e4b1609e9bc6f9f5bd5cc509c4084573833 +2026-07-24-dsh-commander-argument-adapter.md: ac06f37507c8f4e718904fd8c98f17021ff4b5ae +2026-07-24-dsh-commander-argument-adapter.zh.md: 63f37077074f92d167419f9329d3e2e79abf7b4b diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index e023d9ff29..ac06f37507 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -12,7 +12,7 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume); when `--host`/`--port` are given, `--host` must be loopback/all-interfaces and `--port` an integer in 0–65535 (validation moved from the inline `runWeb` checks into the parser). The adapter assigns **no** default for host/port: an absent flag leaves the field undefined, `runWeb` forwards it to `AppCLIEntry` only when present, and the shipped `apps/cli/cordis.yml` `webserver` row is the single source of the host/port default (patched only by an explicit flag). `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. `--config ` replaces an earlier positional config argument. `dsh` is the product front door with no positional; the flag exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an alternate example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. @@ -46,7 +46,7 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev`), and the exit-code behavior for the fail-loud checks (empty resume/prompt, bad host/port, `--prompt` mixed with a config, unknown option) and `--help`/`--version`, captured through a `process.exit` spy. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), and the exit-code behavior for the fail-loud checks it still owns (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional) and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 762e3e4b16..63f3707707 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -12,7 +12,7 @@ Status: implemented argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复);提供 `--host`/`--port` 时,`--host` 必须是回环地址或全接口地址,`--port` 必须是 0–65535 范围内的整数(这两项校验都从 `runWeb` 的内联检查移入解析器)。适配器**不会**为 host/port 设置默认值:未提供某个标志时,对应字段保持 undefined;`runWeb` 仅在相应字段存在时才将 host/port 转发给 `AppCLIEntry`;随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项是 host/port 默认值的唯一真源,只有显式提供标志时才会覆盖该默认值。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 `--config ` 取代了先前的配置位置参数。`dsh` 是不接受位置参数的产品入口;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动另一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 @@ -46,7 +46,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev`),并验证以下情况各自的退出码行为:显式报错检查(恢复 id 或提示词为空、host 或 port 无效、`--prompt` 与配置混用、未知选项)以及 `--help` 和 `--version`;这些退出码通过 `process.exit` spy 捕获。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获它仍负责的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 diff --git a/apps/cli/README.md b/apps/cli/README.md index 1241154d31..6ee3b80976 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,7 +2,7 @@ The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or an invalid `--host`/`--port`/`--resume` value fails loud (stderr, exit 1) instead of misrouting. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. The TUI surface: diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 8c804eddb5..8a0fd5f326 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -11,11 +11,6 @@ import { Command, CommanderError } from 'commander' -/** The loopback host `dsh web` binds by default. */ -export const LOOPBACK_HOST = '127.0.0.1' -/** The all-interfaces host `dsh web` accepts to expose the UI on the LAN. */ -export const ALL_INTERFACES_HOST = '0.0.0.0' - /** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' @@ -31,9 +26,12 @@ interface HeadlessInvocation { /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was - * passed (validated: host is loopback/all-interfaces, port a 0–65535 integer); - * absent means the shipped `cordis.yml` default stands, so the yml is the sole - * source of the default. `dev` mounts the client HMR driver. + * passed — pass-through overrides with no CLI default and no CLI validation: + * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, + * `port` a natural ≤ 65535) is the single source of both the default (the + * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad + * value fails loud at boot). `port` is `Number`-coerced only because the schema + * wants a number, not a string. `dev` mounts the client HMR driver. */ interface WebInvocation { mode: 'web' @@ -45,29 +43,24 @@ interface WebInvocation { /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation -/** Raw web-subcommand options before validation. */ +/** Raw web-subcommand options straight from Commander. */ interface WebOptions { host?: string port?: string dev?: boolean } -/** Validate and narrow the raw `web` options; a bad value fails loud via `command.error`. */ -function resolveWeb(command: Command, options: WebOptions): WebInvocation { - if (options.host !== undefined && options.host !== LOOPBACK_HOST && options.host !== ALL_INTERFACES_HOST) { - command.error(`error: --host must be ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}`) - } - let port: number | undefined - if (options.port !== undefined) { - port = Number(options.port) - if (!/^\d+$/.test(options.port) || !Number.isInteger(port) || port > 65535) { - command.error('error: --port must be an integer in 0-65535') - } - } +/** + * Narrow the raw `web` options into a {@link WebInvocation}. No host/port + * validation: both flow to the webserver schema, which is the sole gate. `port` + * is coerced to a number (the schema rejects a string) but not range-checked + * here — `NaN`/out-of-range fail loud at the schema on boot. + */ +function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', ...options.host !== undefined && { host: options.host }, - ...port !== undefined && { port }, + ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, } } @@ -116,10 +109,10 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') web - .option('--host ', `bind host (${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST})`) - .option('--port ', 'listen port (0 requests an OS-assigned port)') + .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') + .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .action((options: WebOptions) => { resolved = resolveWeb(web, options) }) + .action((options: WebOptions) => { resolved = resolveWeb(options) }) try { program.parse(argv, { from: 'user' }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 1f32c74d0d..ef8a216762 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,21 +1,27 @@ /** * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the * already-parsed host/port/dev, print the URL line, wire signals. All - * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. The - * argument adapter validated host (loopback/all-interfaces) and port (0–65535). + * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and + * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema + * gates them at boot. */ import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' -import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// Display-only mirrors of the webserver schema's allowed hosts: the loopback +// address the local URL always prints, and the all-interfaces value that gates +// LAN-address discovery. Not a source of truth — the schema is. +const LOOPBACK_HOST = '127.0.0.1' +const ALL_INTERFACES_HOST = '0.0.0.0' + /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed * through only when the flag was given; absent, the `cordis.yml` value stands. - * @param host - the bind host ({@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}), or `undefined` to keep the config default. + * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. */ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index a0943d5e66..f186cafca7 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { ALL_INTERFACES_HOST, parseDshArgs } from '../src/args.ts' +import { parseDshArgs } from '../src/args.ts' const parse = (argv: string[]) => parseDshArgs(argv, '1.2.3') @@ -31,18 +31,18 @@ describe('parseDshArgs', () => { expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) - expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: true }) + // Host/port are unvalidated pass-throughs (the webserver schema gates them + // at boot); the adapter only coerces the port string to a number. + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true }) }) - it('exits nonzero instead of silently starting fresh, serving, or dropping inputs', () => { - // Empty resume/prompt would be swallowed downstream; bad host/port must not - // reach the listener; --prompt mixed with TUI inputs must not lose them. + it('exits nonzero instead of silently starting fresh or dropping inputs', () => { + // Empty resume/prompt would be swallowed downstream; --prompt mixed with + // TUI inputs must not lose them. (Bad host/port are gated by the webserver + // schema at boot, not here.) expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) - expect(exitCode(['web', '--host', '10.0.0.1'])).toBe(1) - expect(exitCode(['web', '--port', 'abc'])).toBe(1) - expect(exitCode(['web', '--port='])).toBe(1) expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) From d616d4ca507f7a528b2362eb46cdb092a1395405 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 16:49:45 +0800 Subject: [PATCH 28/31] docs: state the shipped dsh CLI design, not the change history Rewrite the Agent Note's Decision/Resume/front-door/Consequences sections and its Chinese pair in present tense, dropping changelog phrasing ("X replaces an earlier Y", "retired the env var", "which the merge brought in", "Anyone who ran X now uses Y", "an earlier revision dispatched..."). The note now introduces the current grammar directly; Problem and Alternatives keep the motivation and rejected designs the format requires. --- ...7-24-dsh-commander-argument-adapter.i18n.yaml | 4 ++-- .../2026-07-24-dsh-commander-argument-adapter.md | 16 ++++++++-------- ...26-07-24-dsh-commander-argument-adapter.zh.md | 16 ++++++++-------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index d3e2cb30f7..1d7dfa653a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: ac06f37507c8f4e718904fd8c98f17021ff4b5ae -2026-07-24-dsh-commander-argument-adapter.zh.md: 63f37077074f92d167419f9329d3e2e79abf7b4b +2026-07-24-dsh-commander-argument-adapter.md: 1da81a1bdfc64fb6b7565c4881a7be25fb619fd4 +2026-07-24-dsh-commander-argument-adapter.zh.md: 5835d859ea8abf321a3d57bda3218f76569f8c7a diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index ac06f37507..1da81a1bdf 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -14,17 +14,17 @@ Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the `bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. -`--config ` replaces an earlier positional config argument. `dsh` is the product front door with no positional; the flag exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an alternate example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. +`dsh` takes no positional argument. `--config ` names an alternate cordis tree to boot instead of the shipped default; it exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. -`parseResumeArg` is deleted from `dsh-app-boot` (its export, its README row, and its unit block); the pre-release stance permits the removal. `dsh-app-boot` keeps its boot/env/config/personal-overlay helpers — only the argv scanner leaves. +CLI parsing lives entirely in `apps/cli`. `dsh-app-boot` holds the boot/env/config/personal-overlay helpers and no argv scanner. -## Resume without an environment variable +## Session resume through the boot context -Merging the concurrent safe-session-resume feature onto this parser retired the `RESUME_SESSION_ID` environment variable, which had been the only bridge from `--resume` into the shipped config's `resumeSessionId: !!js process.env.RESUME_SESSION_ID`. `runTui` now injects the already-parsed id through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a new `dsh-app-boot` export, value `'resumeSessionId'`) — and the four tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv directly from the parsed values as `dsh --resume= [--config ]`, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. +`dsh --resume ` is the one way to resume a persisted session, with no environment variable. `runTui` provides the parsed id on the boot context through `boot`'s `prepare(ctx)` hook — `ctx.provide(RESUME_SESSION_ID_KEY, id)` (a `dsh-app-boot` export, value `'resumeSessionId'`) — and the shipped tui-agent/cordis configs read it as a bare identifier: `resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`. The expression is quoted because YAML otherwise parses the `?`/`:` as a mapping; the `typeof` guard tolerates a launcher that never provides the slot. The `/resume` in-place handoff (`process.execve`) rebuilds its re-exec argv from the parsed values as `dsh --resume= [--config ]`. ## One terminal front door: `dsh` -The `dsh-tui-demo` package was a plugin (the TUI app bundle mounted by `dsh`'s config) plus a redundant `bin` that booted a leaf `cordis.yml` — the same job `dsh --config ` does. The bin is removed: `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes now launch through `apps/cli/src/bin.ts` with `--config `, and the package keeps only its plugin and invariant entries. The peer/dev `dsh-app-boot` dependency, the `bin`/`./bin` export, the demo's `built-bin.e2e.ts`, and the tsdown `bin` entry all leave with it. `dsh`'s own TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) gains a matching `apps/cli/tests/built-bin.e2e.ts` that runs the built `lib/bin.js` under plain Node with piped stdio (`apps/*/tests` added to the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. +`dsh` is the only terminal entry point; the `dsh-tui-demo` package ships the TUI app bundle plugin the shipped config mounts, and no bin of its own. `demo:cordis`, `demo:code-mode`, and both the tui-agent and cordis-agent keyless PTY smokes launch through `apps/cli/src/bin.ts` with `--config `. `dsh`'s TTY guard (refuse piped stdio before booting, pointing at `dsh -p` for automation) is pinned by `apps/cli/tests/built-bin.e2e.ts`, which runs the built `lib/bin.js` under plain Node with piped stdio (`apps/cli/tests` is in the e2e vitest include). `cli-demo`, `acp-demo`, and `jsonrpc-demo` keep their own bins because each is a distinct surface (headless, ACP, JSON-RPC) `dsh` does not provide. ## Package topology @@ -36,7 +36,7 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages **Keep `parseResumeArg` as a shared helper and feed it Commander's residual args** — rejected: the whole point is to retire the bespoke scanner. Commander parses `--resume` (space and `=` forms, missing-value, position-independence) natively; keeping a parallel hand-written path for the one flag would preserve the duplication the change exists to end. -**Keep the bare `dsh ` positional (and the reserved-`web`-token dispatch it forced)** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional), which is why an earlier revision dispatched a reserved leading `web` token to a second parser and hand-pasted a `web` line into `--help`. The positional existed only so the demo/test sites could boot an alternate tree through the shipped bin. Replacing it with a `--config` flag frees the default surface of any positional, so `web` becomes a normal subcommand in one program with native `--help` — deleting the reserved-token dispatch, the second parser, and the pasted help text. `dsh` loses nothing a user wanted; the demos gain an explicit flag. +**A bare `dsh ` positional for the alternate tree** — rejected: a root positional and a real `web` subcommand cannot coexist in one Commander program (the subcommand claims the first positional). A positional would force `web` into a reserved-first-token dispatch to a separate parser and a hand-maintained `web` line in `--help`. Only the demo/test sites ever need to name an alternate tree, so a `--config` flag serves them while leaving the default surface positional-free — `web` is then a normal subcommand in one program with native `--help`. **Make the argument surface a `packages/*` seam** — rejected: nothing outside `dsh` consumes it, and capability seams are not split preemptively. The Commander adapter is `apps/cli`'s own concern. @@ -46,8 +46,8 @@ The argument surface stays inside `apps/cli`, the assembly tier, not a `packages ## Testing -`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), and the exit-code behavior for the fail-loud checks it still owns (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional) and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. +`apps/cli/tests/args.spec.ts` (new; `apps/*/tests` added to the vitest include and `apps/cli/tests` to `tsconfig.host.json`) covers the adapter at the level that matters: mode routing by shape (including `web --dev` and the host/port pass-through), the exit-code behavior for the adapter's fail-loud checks (empty resume/prompt, `--prompt` mixed with a config/`--resume`, unknown option, stray positional), and `--help`/`--version`, captured through a `process.exit` spy. Host/port validity is the webserver schema's job, exercised on boot by the web smoke, not the adapter spec. Both PTY smoke groups in `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` now drive the real `apps/cli/src/bin.ts`: the `tui-agent` group boots an example tree through `--config`, and the `dsh CLI` group covers default boot, personal overlay, invalid config, the `--resume` config intake, the `process.execve` in-place resume handoff, and the source-path prompt. `examples/cordis-agent/tests/keyless-smoke.e2e.ts` likewise launches through `dsh`. `packages/ui/app-boot/tests/app-boot.spec.ts` drops its `parseResumeArg`/`replaceResumeArg` blocks; the TUI unit and snapshot fixtures use the `dsh --resume {session}` resume command. ## Consequences -`dsh` gains rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing no longer depends on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) now sitting on the CLI's front door. `dsh-app-boot` no longer owns any CLI-parsing surface; a future consumer needing `--resume`-style parsing composes Commander rather than reviving the deleted scanner. Resuming a session needs no environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is now a plugin bundle with no bin. Anyone who ran `dsh-tui-demo ` or `RESUME_SESSION_ID= dsh-tui-demo` uses `dsh ` / `dsh --resume ` instead. +`dsh` has rendered `--help`/`--version` and consistent fail-loud parse errors, and mode routing does not depend on flag position. Argv parsing lives in one place with one parser idiom shared with the SDK bins, at the cost of a `commander` dependency on `apps/cli` and Commander's parse semantics (its error strings, its `exitOverride` contract) sitting on the CLI's front door. `dsh-app-boot` owns no CLI-parsing surface; a consumer needing `--resume`-style parsing composes Commander. Session resume rides the boot context rather than an environment variable, and `dsh` is the single terminal front door — the `dsh-tui-demo` package is a plugin bundle a config mounts. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 63f3707707..5835d859ea 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -14,17 +14,17 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 `bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 -`--config ` 取代了先前的配置位置参数。`dsh` 是不接受位置参数的产品入口;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动另一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 +`dsh` 不接受位置参数。`--config ` 指定一份替代 Cordis 配置树,系统启动该配置树而不是随产品提供的默认配置树;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 -`parseResumeArg` 从 `dsh-app-boot` 中删除(包括其导出、README 中的对应行以及单元测试块);预发布阶段的立场允许这次删除。`dsh-app-boot` 保留其 boot/env/config/个人覆盖辅助函数,只有 argv 扫描器被移除。 +CLI 解析完全位于 `apps/cli` 中。`dsh-app-boot` 提供启动、环境变量、配置和个人覆盖辅助函数,不包含 argv 扫描器。 -## 无需环境变量即可恢复 +## 通过启动上下文恢复会话 -将与本解析器并行开发的安全会话恢复功能合入时,系统移除了 `RESUME_SESSION_ID` 环境变量。此前,它是将 `--resume` 的值传给随产品提供的配置字段 `resumeSessionId: !!js process.env.RESUME_SESSION_ID` 的唯一通道。`runTui` 现在通过 `boot` 的 `prepare(ctx)` 钩子注入已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的新导出,值为 `'resumeSessionId'`);tui-agent 和 cordis-agent 的四份配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)直接根据解析后的值将重新执行的 argv 构造成 `dsh --resume= [--config ]`,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 +`dsh --resume ` 是恢复持久化会话的唯一方式,无需环境变量。`runTui` 通过 `boot` 的 `prepare(ctx)` 钩子,在启动上下文中提供已解析的 id:`ctx.provide(RESUME_SESSION_ID_KEY, id)`(`dsh-app-boot` 的一项导出,值为 `'resumeSessionId'`);随产品提供的 tui-agent/cordis 配置将该值作为裸标识符读取:`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`。这个表达式需要加引号,否则 YAML 会把 `?` 和 `:` 解析为映射;`typeof` 守卫使从未提供该槽位的启动器也能正常运行。`/resume` 原地交接(`process.execve`)根据已解析的值将重新执行时的 argv 构造成 `dsh --resume= [--config ]`。 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh --config ` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `;该包只保留插件入口和不变式入口。与该 bin 一同移除的还有对 `dsh-app-boot` 的对等依赖(peer dependency)和开发依赖、`bin` 和 `./bin` 导出、演示包的 `built-bin.e2e.ts`,以及 tsdown 的 `bin` 入口。`dsh` 自身的 TTY 守卫会在标准输入输出接入管道时,于启动应用前拒绝运行,并提示自动化场景改用 `dsh -p`;为此新增的 `apps/cli/tests/built-bin.e2e.ts` 将标准输入输出接入管道,直接使用 Node 运行构建后的 `lib/bin.js`(`apps/*/tests` 已加入 e2e Vitest 的测试文件匹配范围)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 +`dsh` 是唯一的终端入口;`dsh-tui-demo` 包(package)提供 TUI 应用组合插件,随产品提供的配置会挂载该插件,而该包不提供自己的 bin。`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的无密钥 PTY 冒烟测试都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config `。`dsh` 的 TTY 守卫会在启动前拒绝标准输入输出接入管道的调用,并提示自动化场景使用 `dsh -p`;`apps/cli/tests/built-bin.e2e.ts` 锁定了这一行为:该测试将标准输入输出接入管道,并通过普通 Node 运行构建后的 `lib/bin.js`(e2e Vitest 的 include 包含 `apps/cli/tests`)。`cli-demo`、`acp-demo` 和 `jsonrpc-demo` 保留各自的 bin,因为它们分别提供 `dsh` 所没有的独立接口(headless、ACP(Agent Client Protocol)、JSON-RPC)。 ## 包拓扑 @@ -36,7 +36,7 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 **保留 `parseResumeArg` 作为共享辅助函数,并向它喂入 Commander 的残余参数。** 已否决:整件事的核心就是要退役这个定制扫描器。Commander 原生解析 `--resume`(空格和 `=` 形式、缺值、位置无关性);为这一个标志保留一条平行的手写路径,只会保留这次变更要终结的重复。 -**保留裸 `dsh ` 位置参数(以及它迫使系统采用的保留 `web` token 分发机制)。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。因此,先前版本才会把开头保留的 `web` token 分发给第二个解析器,并在 `--help` 中手工拼接一行 `web` 文本。该位置参数仅用于让演示和测试调用点通过随产品提供的 bin 启动另一份示例树。将其替换为 `--config` 标志后,默认接口不再包含任何位置参数,`web` 因而成为单个程序中的普通子命令,并由原生 `--help` 展示;保留 token 分发、第二个解析器和手工拼接的帮助文本均被删除。`dsh` 没有损失任何用户所需的功能,演示调用则改用显式标志。 +**使用裸 `dsh ` 位置参数指定替代配置树。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。位置参数会迫使系统把位于首位的 `web` 作为保留 token 分发给另一个解析器,并手工维护一行 `web` 文本,供 `--help` 显示。只有演示和测试调用点需要指定替代配置树,因此 `--config` 标志既能满足这些调用点,又能让默认接口不包含位置参数;这样,`web` 就能在单个程序中成为普通子命令,并由原生 `--help` 展示。 **把参数解析做成 `packages/*` 的 seam。** 已否决:`dsh` 之外没有任何消费方使用它,而能力 seam 不应被提前拆分。这个 Commander 适配器是 `apps/cli` 自身的事务。 @@ -46,8 +46,8 @@ argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配 ## 测试 -`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获它仍负责的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 +`apps/cli/tests/args.spec.ts`(新增;`apps/*/tests` 加入 vitest include,`apps/cli/tests` 加入 `tsconfig.host.json`)覆盖适配器的关键行为:根据参数形态进行模式路由(包括 `web --dev` 和 host/port 透传),并通过 `process.exit` spy 捕获适配器的显式报错检查(恢复 id 或提示词为空、`--prompt` 与配置或 `--resume` 混用、未知选项、多余的位置参数)以及 `--help`/`--version` 的退出码。host/port 的有效性由 webserver schema 负责,并由 web 冒烟测试在启动时验证,不属于适配器测试的覆盖范围。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 中的两组 PTY 冒烟测试现在都驱动真实的 `apps/cli/src/bin.ts`:`tui-agent` 组通过 `--config` 启动示例树,`dsh CLI` 组覆盖默认启动、个人覆盖、无效配置、配置对 `--resume` 的接收、通过 `process.execve` 原地恢复交接,以及包含源码路径的系统提示词。`examples/cordis-agent/tests/keyless-smoke.e2e.ts` 同样通过 `dsh` 启动。`packages/ui/app-boot/tests/app-boot.spec.ts` 移除其 `parseResumeArg` 和 `replaceResumeArg` 测试块;TUI 单元测试和快照 fixture(测试前置数据)使用 `dsh --resume {session}` 恢复命令。 ## 影响 -`dsh` 获得了渲染出的 `--help`/`--version` 以及一致的显式报错式解析错误,模式路由也不再依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 新增一项 `commander` 依赖,且 Commander 的解析语义(它的错误字符串、它的 `exitOverride` 契约)如今落在 CLI 的入口处。`dsh-app-boot` 不再拥有任何 CLI 解析职责;未来需要 `--resume` 式解析的消费方应组合 Commander,而不是复活已删除的扫描器。恢复会话不再需要环境变量,且 `dsh` 是唯一的终端入口;`dsh-tui-demo` 包现在是一个不带 bin 的插件组合包。原先运行 `dsh-tui-demo ` 或 `RESUME_SESSION_ID= dsh-tui-demo` 的用户,改用 `dsh ` 或 `dsh --resume `。 +`dsh` 会渲染 `--help`/`--version`,并以一致方式显式报告解析错误;模式路由不依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 依赖 `commander`,且 Commander 的解析语义(错误字符串和 `exitOverride` 契约)成为 CLI 入口的一部分。`dsh-app-boot` 不提供任何 CLI 解析接口;需要 `--resume` 式解析的消费方通过组合 Commander 来实现。会话恢复通过启动上下文完成,而不使用环境变量;`dsh` 是唯一的终端入口;`dsh-tui-demo` 包是由配置挂载的插件组合包。 From 5a06b9e92612ee92126d671a9b69b027507efca8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 17:24:39 +0800 Subject: [PATCH 29/31] fix(cli): reject default-surface flags leaked onto the web subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ds-review-bot: `dsh web -p task`, `dsh web --resume s`, and `dsh --config c.yml web` reached the web action with those values in program.opts() but the action ignored them and served — silently dropping mode-specific inputs. The web action now reads the parent opts and fails loud (exit 1) on a leaked --config/-p/--resume, matching the root mode's mixing guard. Covered in args.spec.ts. Also (ds-review-bot): tui-demo/README documented the removed `dsh [path-to-cordis.yml]` positional form; corrected to bare `dsh` / `dsh --config `. Agent Note + Chinese pair note the web-leak guard. --- ...26-07-24-dsh-commander-argument-adapter.i18n.yaml | 4 ++-- .../2026-07-24-dsh-commander-argument-adapter.md | 2 +- .../2026-07-24-dsh-commander-argument-adapter.zh.md | 2 +- apps/cli/src/args.ts | 12 +++++++++++- apps/cli/tests/args.spec.ts | 5 +++++ packages/examples/tui-demo/README.md | 2 +- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml index 1d7dfa653a..d437141cd1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.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-dsh-commander-argument-adapter.md: 1da81a1bdfc64fb6b7565c4881a7be25fb619fd4 -2026-07-24-dsh-commander-argument-adapter.zh.md: 5835d859ea8abf321a3d57bda3218f76569f8c7a +2026-07-24-dsh-commander-argument-adapter.md: c304cac5870af838794df85a18be63ca85ce06eb +2026-07-24-dsh-commander-argument-adapter.zh.md: fb16f89c84c27d42f7aa638c5b019c52ce51068e diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md index 1da81a1bdf..c304cac587 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.md @@ -12,7 +12,7 @@ The `dsh` CLI entry (`apps/cli`) parsed argv in three hand-rolled idioms that di Argv is parsed once, in `apps/cli/src/args.ts`, through a Commander adapter (the same parser the SDK bins — `create-sdk`, `dsh-scripts` — already standardize on). `parseDshArgs(argv, version)` returns a discriminated `DshInvocation` union of the three real modes: `{ mode: 'tui', config?, resume? }`, `{ mode: 'headless', prompt }`, or `{ mode: 'web', host?, port?, dev }`. It does **not** model help/version/errors as data: Commander owns those, printing usage or the diagnostic and exiting at the point of failure. `exitOverride()` turns each into a thrown `CommanderError` carrying the intended code (0 for help/version, 1 for a parse or domain error), which one `try/catch` in `parseDshArgs` turns into `process.exit`. -`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. +`bin.ts` calls the adapter once and switches on `mode` (closed union, `satisfies never` default), dynamic-importing only the chosen mode's module; only a valid, non-help invocation reaches the switch, so it has no help/version/error cases. Each mode module consumes already-parsed values: `runTui(config, resume)`, `runHeadless(task)`, `runWeb(host, port, dev)` — none re-reads argv. It is **one Commander program**: the default surface (no subcommand) carries option-only flags — `--config `, `-p/--prompt `, `--resume ` — and `web` is a real `program.command('web')` subcommand. The default surface takes no positional argument, which is what lets `web` be a real subcommand without a positional collision, so `dsh --help` lists `web` natively (no hand-pasted command text). The default action and the `web` action set the resolved mode, then bail via `command.error(...)` (print + exit 1) on the domain checks Commander cannot express: `--prompt` selects headless and rejects an empty task or a `--config`/`--resume` alongside it rather than silently dropping a TUI input; an empty `--resume=` id fails loud (agent-loop treats `''` as no-resume). Commander parses the default-surface options on either side of the `web` token into `program.opts()`; since `web` shares none of them, the `web` action rejects a leaked `--config`/`-p`/`--resume` (`dsh web -p x`, `dsh --config c.yml web`) rather than silently serving and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the adapter assigns no default and does no validation, only `Number`-coercing the port string (the schema wants a number). The `dsh-host-webserver` schemastery `Config` (`host` a `127.0.0.1`/`0.0.0.0` literal union, `port` a natural ≤ 65535) is the single source of both the default (the shipped `apps/cli/cordis.yml` `webserver` row stands when a flag is absent) and validity — `AppCLIEntry` patches an explicit flag straight into that row, so a bad host/port fails loud at the schema on boot, not at parse. `--dev` mounts the client HMR driver and bundle watch. A repeated `--resume`, or a following flag captured as a `--resume`/`--prompt` value, is Commander's standard behavior (last-wins / next-token) and is left alone; a bad id fails loud downstream when the session cannot load. `--version` reads this app's `package.json`. `dsh` takes no positional argument. `--config ` names an alternate cordis tree to boot instead of the shipped default; it exists only so the demo/test call sites (`demo:cordis`, `demo:code-mode`, the keyless PTY smokes) can point the shipped bin at an example tree. A bare `dsh` boots the shipped tree plus the `~/.dsh/config.yaml` personal overlay; a real user never passes `--config`. diff --git a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md index 5835d859ea..fb16f89c84 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-dsh-commander-argument-adapter.zh.md @@ -12,7 +12,7 @@ Status: implemented argv 只在 `apps/cli/src/args.ts` 中解析一次,并使用 Commander 适配器(SDK bin `create-sdk`、`dsh-scripts` 已经统一采用的同一解析器)。`parseDshArgs(argv, version)` 返回仅包含三种实际模式的判别式 `DshInvocation` 联合类型:`{ mode: 'tui', config?, resume? }`、`{ mode: 'headless', prompt }` 或 `{ mode: 'web', host?, port?, dev }`。它**不会**将帮助、版本信息或错误建模为数据:这些情况由 Commander 处理,在触发处打印用法或诊断信息并退出。`exitOverride()` 会将每种情况转为抛出的 `CommanderError`,并携带预期退出码(帮助或版本为 0,解析错误或领域错误为 1);唯一一处 `try/catch` 位于 `parseDshArgs` 中,捕获错误后调用 `process.exit`。 -`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 +`bin.ts` 只调用适配器一次,并对 `mode` 做分支切换(封闭联合类型,默认分支为 `satisfies never`),仅动态导入所选模式对应的模块;只有合法的非帮助请求才会进入这段分支逻辑,因此其中没有帮助、版本或错误分支。每个模式模块只消费已解析好的值:`runTui(config, resume)`、`runHeadless(task)`、`runWeb(host, port, dev)`,都不会再次读取 argv。整个 CLI 由**单个 Commander 程序**实现:默认接口(不使用子命令时)只包含选项标志——`--config `、`-p/--prompt `、`--resume `——而 `web` 是通过 `program.command('web')` 定义的真正子命令。默认接口不接受位置参数,因此 `web` 可以成为真正的子命令且不会发生位置参数冲突,`dsh --help` 也会原生列出 `web`,无需手工拼接命令文本。默认命令和 `web` 子命令的处理函数会设置解析得到的模式,随后对 Commander 无法表达的领域校验调用 `command.error(...)` 立即终止(打印信息并以退出码 1 退出):`--prompt` 选择 headless 模式;如果任务为空,或调用中还包含 `--config` 或 `--resume`,它会拒绝调用,而不会静默丢弃 TUI 输入;空的 `--resume=` id 会显式失败(agent-loop 把 `''` 视为不恢复)。Commander 会将 `web` token 前后的默认接口选项都解析进 `program.opts()`;由于 `web` 不与默认接口共用任何选项,`web` 子命令的处理函数会拒绝误入的 `--config`/`-p`/`--resume`(`dsh web -p x`、`dsh --config c.yml web`),而不是静默启动服务并丢弃这些选项。`dsh web` 的 `--host`/`--port` 是未经校验、直接透传的覆盖值:适配器既不设置默认值,也不执行校验,只使用 `Number` 将端口字符串转换为数字(schema 要求该值为数字)。`dsh-host-webserver` 的 schemastery `Config`(`host` 是 `127.0.0.1`/`0.0.0.0` 字面量联合类型,`port` 是不大于 65535 的自然数)是默认值与有效性的唯一真源:未提供标志时,随产品提供的 `apps/cli/cordis.yml` 中 `webserver` 配置项保持原值;`AppCLIEntry` 将显式标志的值直接写入该配置项,因此无效的 host/port 会在启动时触发 schema 校验并显式失败,而不是在参数解析阶段失败。`--dev` 会挂载客户端 HMR(热模块替换)驱动,并启用构建产物监视。重复提供 `--resume`,或后续标志被捕获为 `--resume` 或 `--prompt` 的值,都是 Commander 的标准行为(最后一次取值生效/将下一 token 作为值),本适配器不作干预;无效 id 会在下游无法加载会话时显式失败。`--version` 读取本应用的 `package.json`。 `dsh` 不接受位置参数。`--config ` 指定一份替代 Cordis 配置树,系统启动该配置树而不是随产品提供的默认配置树;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 8a0fd5f326..87cca9ce19 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -112,7 +112,17 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .action((options: WebOptions) => { resolved = resolveWeb(options) }) + .action((options: WebOptions) => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `web` shares none of them, so a + // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must + // fail loud rather than silently start the web server and drop it. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error('error: web takes none of --config, -p/--prompt, or --resume') + } + resolved = resolveWeb(options) + }) try { program.parse(argv, { from: 'user' }) diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index f186cafca7..a591b80f6a 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -47,6 +47,11 @@ describe('parseDshArgs', () => { expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) + // A default-surface flag on either side of `web` leaks into program.opts() + // but the web subcommand shares none of them: reject rather than serve. + expect(exitCode(['web', '-p', 'task'])).toBe(1) + expect(exitCode(['web', '--resume', 's'])).toBe(1) + expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index b6c80687a4..6af2addb8f 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -49,7 +49,7 @@ Fresh runs mint a `main-session-` session id and pass it to both the TUI a ## Front door -This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: `dsh [path-to-cordis.yml]` boots a leaf config that mounts this bundle (defaulting to the shipped `examples/tui-agent/cordis.yml`), loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. +This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config ` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. ## Example leaf From 67e2ef8ef269369cb0c386c8c25a20ed898c59d5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 17:37:50 +0800 Subject: [PATCH 30/31] chore: retrigger CI (synchronize event was missed) From b78daaad8c97484754d5fb0fe706d4cac41c3dfa Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Sat, 25 Jul 2026 18:14:36 +0800 Subject: [PATCH 31/31] 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',