From b92235c490e4d831a27fc2775048b9cc5434ba3e Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 14:26:25 +0800 Subject: [PATCH 01/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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/70] 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 fd6713b4967506e654dd7bdf77b067f4ffc57f96 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 00:20:38 +0800 Subject: [PATCH 13/70] docs(testing): propose keyless browser e2e lane for the web GUI Design study for a deterministic, keyless browser e2e lane over the real assembled web chain (chromium -> SSE/HTTP wire -> apiproxy -> agent loop -> persistence), replayed through dsh-llm-replay from recorded session-log fixtures, with aria-tree goldens plus in-process world-state assertions. Synthesized from an OSS prior-art survey (LibreChat, ai-chatbot, lobe-chat, OpenHands, cline, aimock...), a repo seam deep-dive, and three adversarial critiques (doctrine, flakiness, YAGNI). Records the settled shape (no new package, no suite factory, seed via the real persistence API, whenIdle barrier stack, no transient-DOM assertions) and the open questions (LLM seam, Loader-izing dsh web, header pin, golden breadth, settled signal). --- .../2026-07-24-web-gui-browser-e2e-lane.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md diff --git a/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md new file mode 100644 index 0000000000..cc7504dae7 --- /dev/null +++ b/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -0,0 +1,92 @@ +# Agent Note: Keyless browser e2e lane for the web GUI + +Status: proposed + +## Problem + +The web GUI ships as a real assembled chain — chromium page → nine client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → `bootHost`'s agent loop, tools, and JSONL persistence — and no test exercises that chain keylessly and deterministically. The [GUI testing system](../../implemented/process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and a tier-3 smoke pair, but the keyless smoke (`apps/web/tests/smoke-fixture.e2e.ts`) drives `FixtureApiClient` behind `?fixture` — no host, no wire, no agent loop — while the full-chain smoke (`smoke-real.e2e.ts`) needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface is the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. + +## Proposal + +Add a keyless, deterministic browser e2e lane under `apps/web/tests/`, driven by recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay`, asserting the rendered accessibility tree plus in-process world state. No new package; no product-code change except (open question 1) an LLM composition knob. + +### Harness: `apps/web/tests/harness.ts` + +A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic this lane needs — replay derivation, session parsing, log scrubbing, persistence — already lives in gated packages (`dsh-llm-replay`, `dsh-acp-snapshot`, `dsh-session-persistence-jsonl`); what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. + +`launchWebHarness()` boots the real web assembly in-process from the exported production functions — `startHost({ boot: { persistenceRoot: , workspaceContext: false, cwd: } })`, `installLlmReplay(host.ctx, { file, childFiles, providers })`, `mountWebPlugins(host.ctx)`, `createHostWebPluginRegistry`, `startWebServer({ port: 0, distIndex, apiHandler: host.handler, webPlugins })` — and returns `{ baseUrl, host, workspaceCwd, close }`. This is the web analog of the TUI suite mounting the production bundle in-process ([TUI snapshots](../../implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)): the real entry boundary (`dsh web` bin arg-parsing and dist resolution) stays held by the existing keyless CLI smokes in `smoke-real.e2e.ts`, and the web surface has no `cordis.yml` to bypass — assembly is written in the app per the [GUI layering decision](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). Replay runs in providers-catalog mode with a `contextWindow` (the TUI suite's `PROVIDERS` shape), never catch-all: with no adapter registered, catch-all mode would make `compact-basic`'s `resolveModelContext` throw into its post-step catch every step, spamming warnings and silently disabling the pressure path instead of proving it inert. + +`seedSession(host, fixtureText)` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` for deterministic sidebar order (the precedent is `examples/acp-agent/tests/semantic-checkpoint.snapshot.ts`) — never raw file writes, so the seeder needs no knowledge of bucket hashing, filename encoding, or compression. Seeds are validated at seed time (parseable, `seq`-contiguous, ending in `turn/end`) so fixture drift fails loud at the earliest resolvable point rather than as silently dropped frames in the client; a seed not ending in `turn/end` would be mutated by resume's crash repair. + +### Determinism rules + +The barrier stack, in order, for a prompted turn: (1) host-side `await agent.whenIdle()` under a timeout — the idle flip happens after both the `turn/end` append and the persistence flush, so one await covers turn completion and durability; (2) browser settled poll — streaming node detached, composer restored, final text visible; (3) log harvest only after `host.dispose()`. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync), and file polling is banned (slow on NFS, superseded by `whenIdle`). For history-open scenarios the barrier is a poll for the last expected message's text, then a poll-until-equal aria capture. `networkidle` is banned outright — it never resolves while an SSE stream is open. + +No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof), optionally corroborated by an in-page MutationObserver latch armed before send — observers cannot miss a commit; polls can. `dsh-llm-replay` gains an opt-in `paceMs` config field (default absent = today's instant yield) as a realism knob so the browser observes genuinely incremental SSE; correctness never leans on the pace. + +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Harness `close()` asserts every replay script was fully consumed (all scripts bound, every cursor at end), converting silent underruns and shifted bindings into crisp diagnostics; this is a small additive stats handle on `installLlmReplay`. No vitest retry on the lane — a retried-green race is a flake deferred, and only the chromium launch itself may retry inside the harness, logged. One chromium per file, fresh browser context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text only. + +### Expected outputs + +One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region only (`ui.expected.md`) — uuid/cwd/duration tokens normalized, sidebar and other time-bearing chrome structurally excluded, captured poll-until-equal at the settled milestone. The accessibility tree is the mechanization of the client rule "assert what the user would see, never class names": it survives CSS-module hash churn, styling rewrites, and DOM restructuring, and a wholesale component rewrite refreshes it keylessly. Alongside the golden, three or four targeted role/text anchor assertions (heading level, `pre code` content, tool-row accessible name) keep green anchors under a semantics-preserving rewrite so a churned golden diff is reviewable against surviving anchors. World-state assertions ride `host.ctx` session events inline (which tools ran, `turn/end` completed, no error) instead of a second committed log golden: the persisted-log surface is already pinned by the ACP/headless/TUI suites through the same loop and persistence plugins, and re-pinning it here would double refresh cost for no new regression class. `playwright` gets pinned exactly in `apps/web/package.json` — the aria format is Playwright-owned, the one snapshot format in this repo we do not own, so version bumps must be deliberate bump-and-refresh commits. + +### Modes and fixtures + +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless), as inline branches in the specs — the TUI suite's shape, not a suite-factory: at two scenarios the acp-snapshot factory machinery (scenario tables, pinning classes, Windows sidecars, packed-row stabilization) has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `normalizeSessionLog`, `parseSessionLog`, `installLlmReplay`). Each scenario script splits into drive steps (type, send, `whenIdle`-generic waits — run in all modes, never waiting on model-content selectors) and interaction/assertion steps (expand reasoning, aria capture — replay/refresh only), so record mode cannot hang on a live model answering with a different tool count. Record = drive + harvest the in-memory `session.header` + `session.events` (the TUI `rawSessionLog` shape — no file decompression, so `bootHost` needs no compression knob) + scrub via `scrubRequestHeaders` + a mandatory keyless refresh to regenerate `ui.expected.md`. Web fixtures scrub headers everywhere and pin nowhere, matching the TUI precedent; whether the web surface must instead own a header-class pin per the [pinned-header discipline](../../implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md) is open question 3. Seeds are recorded fixtures under the same inventory and refresh discipline as replay fixtures, never hand-authored one-offs, so `DSH_SNAPSHOT=refresh` heals every committed surface after intentional shape churn and only `assistant/chunk`-shape churn escalates to re-record. A TUI-style `afterAll` fixture guard holds the inventory closed (expected files present, every fixture scrub-fixed-point, no orphan directories). + +### Demo scenarios + +1. **`fresh-round-trip`** — new session, prompt, replay streams reasoning + markdown + a `bash` tool call that really executes (`echo` in the temp workspace) + final text. Asserts settled markdown semantics (heading, code block), the tool card row, composer restore, the aria golden, and inline world state (bash `tool/call` + completed `turn/end` in the session events). The keyless version of the with-key W5 flows. +2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it, opening it renders tool cards and collapsible reasoning purely from the log. This exercises the implicit cold-resume attach (`session.history` resumes via `agentFor`), cold summaries, history pagination views, and the client fold of historical events — the surface nothing else covers — with zero model calls, so no replay-binding constraints at all. A follow-up-prompt-after-resume scenario is deliberately deferred until the history/live stitch path changes or regresses. + +### Lane wiring and CI stance + +The lane rides `vitest.web.config.ts` (`pnpm run test:web`, serial), which stays gate-exempt exactly as its header comment records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise recorded in the [GUI testing system note](../../implemented/process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from that note, staged as: non-required CI job first, promotion criteria measured (consecutive green runs, wall time, zero-retry flake budget, browser cache strategy on the enterprise runners, whether the runner images carry the chromium system libraries). Deferred out of this proposal; a `TODO(ci-browser)` marks the seam. Scenarios are `posixOnly` initially. Docs updated in the same implementation PR: the [testing policy](../../../../docs/testing.md) names `apps/web/tests/snapshots/` as the web surface's snapshot home with its divergent `DSH_SNAPSHOT=… pnpm run test:web` commands, the GUI testing note's tier map gains the lane (and drops its stale references to the deleted `missions/scripts/verify-*` files), `packages/client/AGENTS.md`'s check ladder mentions it, and the `dsh-acp-snapshot` README's "ACP-specific by design" sentence is corrected — this lane is the third consumer of its normalizers. + +### Open questions + +1. **LLM seam.** Two viable shapes. (a) `BootHostOptions.llm?: 'deepseek' | false` — an assembly toggle in the one module that owns assembly, matching the existing `workspaceContext: Config | false` shape and the reserved-knob sentence in `start.ts`; `llm: false` mounts no adapter, the harness fills the open seam on `host.ctx`, misuse fails loud at the first stream with `NO_ADAPTER`, and keyless boots stop needing any key. (b) Zero product change — a placeholder `DEEPSEEK_API_KEY` env var satisfies `llm-deepseek`'s load-time presence check (twice-precedented in-tree) and replay intercepts ahead of the mounted adapter. (a) is cleaner semantics and honest keylessness at the cost of a test-motivated product field; (b) is free but satisfies a fail-loud check with a lie and leaves a dead adapter mounted. Recommendation: (a), shaped minimal. +2. **Loader-izing `dsh web`.** Making the web host `cordis.yml`-driven like every example would give the ACP-style `cordis.snapshot.yml` replay overlay for free and align with "everything is a plugin", but it reverses the settled "assembly is written in the app" ruling and is a product-architecture decision on its own merits — its own proposal if wanted; this lane does not need it. +3. **Header-class pin.** Strict reading of the pinned-header discipline wants one web scenario pinning `bootHost`'s composed prompt + tool schemas (a header class no ACP scenario covers); the TUI precedent scrubs everywhere and pins nowhere. Cheap middle: pin sidecars on `fresh-round-trip` at record time. Recommendation: follow TUI now (scrub-only), revisit when the web assembly's header diverges further from the repl composition it mirrors. +4. **Golden breadth.** Full conversation-region aria golden (adopted above) versus targeted assertions only. The golden is the "assembled transcript" duty for user-visible changes; the cost is a keyless refresh on every component rewrite. Recommendation: keep the golden + anchors. +5. **Client settled signal.** A `data-dsh-busy` attribute derived from the object layer's pending-RPC/active-stream state would replace multi-condition settled polls with one selector. Presentation-plane observability, no session-log leak — but the current polls suffice for two scenarios. Recommendation: defer until a settled-poll flake actually appears. + +## Prior art + +Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural. + +## Alternatives considered + +**Browser-network SSE interception (`page.route`).** Rejected: `route.fulfill` cannot stream, so incremental token rendering is unexercisable and the server-side SSE/backpressure/close path — where both confirmed P0s hid — goes untested. + +**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job. + +**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction. + +**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected for now: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios the factory generalizes from one consumer while the genuinely shared logic already lives exported in `dsh-llm-replay`/`dsh-acp-snapshot`. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. + +**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers, against the tier discipline. Inline world-state assertions on `host.ctx` events keep the world-verification duty. + +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected for now: it needs a test-mode branch plus env plumbing in the product bin where the in-process route uses exported production functions with zero product change; the bin's thin glue is covered by the keyless CLI smokes. Becomes free if the web host is ever Loader-ized (open question 2). + +**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. + +**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. + +## Acceptance criteria + +- `pnpm run test:web` keyless passes the two scenarios deterministically (no vitest retry), alongside the existing smoke pair, on a checkout with built client bundles and the frontend dist. +- Replay asserts: aria golden equality at the settled milestone, anchor role/text assertions, inline world-state event assertions, zero pageerrors, zero connection-loss/gap-repair console warnings, all replay scripts fully consumed at teardown. +- `DSH_SNAPSHOT=record` with a key re-records `fresh-round-trip` (drive steps only), rewrites its `session.jsonl` scrubbed, and a follow-up `DSH_SNAPSHOT=refresh` regenerates `ui.expected.md` keylessly; `refresh` alone heals goldens after intentional non-chunk shape churn. +- The seeded scenario renders history through the real cold-resume path with zero model calls and leaves the seed fixture byte-identical (closedness validated at seed time). +- Fixture guard holds the snapshot inventory closed; failure produces a bundle under `.artifacts/` (screenshot, console, pageerrors, persistence copy, actual-vs-expected aria). +- Docs land in the same PR: testing.md web-lane entry, GUI testing note tier map + stale verify-script cleanup, client AGENTS.md ladder, acp-snapshot README correction, this note moved to `implemented/` rewritten in present tense. + +## Risks + +- **The aria format is Playwright-owned** — the one committed snapshot format the repo does not control; a version bump can churn every golden. Mitigated by an exact version pin in `apps/web` and a documented bump-and-refresh procedure; residual risk accepted. +- **Replay's first-call-order binding** stays fragile under concurrent browser-driven sessions; the lane constrains scenarios to one prompting session each (the seeded scenario prompts none), and the teardown consumption assertion turns violations into diagnostics rather than surreal transcripts. +- **`compact-basic` shares the session's replay cursor** — a pressure-triggered summarize would consume a script entry; inert for small fixtures under the 128k catalog window, and the consumption assertion catches it if a fixture ever grows past the threshold. +- **CI remains browserless for now**, so the lane guards regressions only where it is run (locally and in any future non-required job) until the CI reversal is separately decided; the runner images' chromium-library situation is unverified. +- **Record-mode nondeterminism** is contained but not eliminated by the drive/assert split: a live model may still produce a transcript whose replay violates a scenario's assertions, requiring prompt tuning at record time (bounded by terse prompts and a chunk-count warning in record mode). +- **jsdom-lane overlap**: component-level rendering is already covered per-plugin; this lane must stay at assembled-transcript altitude (whole-region golden + anchors) or it starts re-testing tier 2 and paying double maintenance. From 9ef0193dd56a56ca4792c12f8296b035201dee85 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:08:10 +0800 Subject: [PATCH 14/70] feat(host-runtime,llm-replay): keyless llm seam + replay pacing/consumption handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BootHostOptions.llm: 'deepseek' | false — false mounts no adapter, boots keyless, and leaves the llm capability seam open for the embedder to fill on RunningHost.ctx (now the third sanctioned ctx use, JSDoc + README amended); an unfilled seam fails loud with NO_ADAPTER at the first stream. dsh-llm-replay grows two additive surfaces for the web browser e2e lane: paceMs (validated per-chunk delay so a real transport shows incremental delivery; abort during a pace wait cancels promptly) and a ReplayHandle return — dispose() plus assertConsumed(), the teardown check that every recorded script bound and drained, converting silent fixture underruns into diagnostics. Existing callers updated; config catalog regenerated. --- docs/config-catalog.md | 4 +- packages/host/runtime/README.md | 3 +- packages/host/runtime/src/boot.ts | 11 ++- packages/host/runtime/src/start.ts | 9 +- .../host/runtime/tests/host-runtime.spec.ts | 36 ++++++++ packages/support/llm-replay/README.md | 5 +- packages/support/llm-replay/src/index.ts | 88 +++++++++++++++++-- .../llm-replay/tests/llm-replay.spec.ts | 63 ++++++++++++- 8 files changed, 201 insertions(+), 18 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..c06fef3d4d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -616,6 +616,8 @@ export interface Config { childFiles?: string[] /** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */ providers?: ReplayProviderConfig[] + /** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */ + paceMs?: number } /** One provider route exposed by the replay adapter. */ @@ -641,7 +643,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:387`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:453`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index f7cf7d9de8..4384f591a1 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -2,7 +2,7 @@ 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 }`. -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. +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 three sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`), headless session-event subscription, and filling a capability seam the boot options deliberately left open (`llm: false` → the embedder installs its own LLM backend, e.g. the keyless web e2e harness's replay); consuming clients must not bypass `api` through it. ## Configuration @@ -10,6 +10,7 @@ Which plugins mount and with what defaults is decided only here — shells must |---|---:|---| | `persistenceRoot` | (required) | Root directory for JSONL session persistence. | | `workspaceContext` | (required) | [`AGENTS.md`/`CLAUDE.md` loader](../../context/workspace-context/README.md) config with an explicit `maxBytes`, or `false` to disable it. | +| `llm` | `'deepseek'` | LLM adapter selection: `'deepseek'` mounts the DeepSeek adapter (API key required at load); `false` mounts none, boots keyless, and leaves the `llm` seam open for the embedder — an unfilled seam fails loud with `NO_ADAPTER` at the first stream. | | `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`. | | `cwd` | `process.cwd()` | Default project directory for a session whose create request omits `cwd`. | diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index c0960ab678..e2f24a98ac 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -65,6 +65,15 @@ export interface BootHostOptions { persistenceRoot: string /** Workspace-instruction byte budget/config, or false to disable AGENTS.md/CLAUDE.md loading. */ workspaceContext: workspaceContext.Config | false + /** + * LLM adapter selection: `'deepseek'` (default) mounts the DeepSeek adapter + * (requires an API key at load), `false` mounts no adapter and leaves the + * `llm` capability seam open for the embedder to fill on the returned ctx + * (e.g. the keyless web e2e harness installing a replay backend). With + * `false` and nothing filled, the first stream fails loud with NO_ADAPTER — + * the earliest resolvable point for an open capability seam. + */ + llm?: 'deepseek' | false /** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */ provider?: string /** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */ @@ -129,7 +138,7 @@ export async function bootHost(options: BootHostOptions): Promise { await ctx.plugin(AgentRegistry) await ctx.plugin(TaskService) await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek, {}) + if (options.llm !== false) await ctx.plugin(LlmDeepSeek, {}) await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot }) await ctx.plugin(LocalBashExecutor, {}) // Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml + diff --git a/packages/host/runtime/src/start.ts b/packages/host/runtime/src/start.ts index 94e5f22da1..c389c0de68 100644 --- a/packages/host/runtime/src/start.ts +++ b/packages/host/runtime/src/start.ts @@ -34,9 +34,12 @@ export interface RunningHost { /** * Root context — a formal seam, not an escape hatch: (1) the mount point for * protocol front-door plugins (`dsh acp` = startHost() → ctx.plugin(uiAcp, config)); - * (2) headless session-event subscription. Discipline: consuming clients must - * not bypass `api` through ctx; shells must not ctx.plugin to alter the - * assembly (mounting a front door is the shell's own shape, not an assembly change). + * (2) headless session-event subscription; (3) filling a capability seam the + * boot options deliberately left open (`llm: false` → the embedder installs + * its own LLM backend, e.g. keyless replay). Discipline: consuming clients + * must not bypass `api` through ctx; shells must not ctx.plugin to alter the + * assembly (mounting a front door or filling an explicitly-open seam is the + * shell's own shape, not an assembly change). */ ctx: Context /** Single shutdown exit (ctx.fiber.dispose()). Idempotent: a second call returns the same promise. */ diff --git a/packages/host/runtime/tests/host-runtime.spec.ts b/packages/host/runtime/tests/host-runtime.spec.ts index 4058fdfe2e..92d6534831 100644 --- a/packages/host/runtime/tests/host-runtime.spec.ts +++ b/packages/host/runtime/tests/host-runtime.spec.ts @@ -205,6 +205,42 @@ describe('bootHost / startHost', () => { expect((await ctx.sessionTitle.refresh(agent.session))?.source).toEqual({ kind: 'fallback' }) expect(agent.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false) }) + + it('llm: false boots keyless with no adapter and fails loud at the first stream', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', '') + const handle: HostHandle = await bootHost({ + persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-keyless-')), + workspaceContext: false, + llm: false, + }) + // The seam is open: nothing routes 'deepseek', so misuse surfaces at the + // earliest resolvable point instead of silently doing provider I/O. + await expect(async () => { + for await (const chunk of handle.ctx.llm.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [], + })) void chunk + }).rejects.toThrow(/NO_ADAPTER|no adapter/i) + // The embedder can fill the open seam on the returned ctx (the sanctioned + // RunningHost.ctx use) and streams route through the filled adapter. + class ProbeAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + yield * textResponse('keyless-ok') + } + } + handle.ctx.llm.registerAdapter(['deepseek'], new ProbeAdapter()) + const collected: string[] = [] + for await (const chunk of handle.ctx.llm.stream({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [], + })) { + if (chunk.type === 'text-delta') collected.push(chunk.text) + } + expect(collected.join('')).toBe('keyless-ok') + await handle.dispose() + }) }) describe('host.describe', () => { diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 0d89d4337d..e655aa4077 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -24,6 +24,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | | `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | +| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | ```yaml - id: llm-replay @@ -43,11 +44,11 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s ## Exports -- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars. +- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars. - `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. - `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). - `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. -- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `Config`. +- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index ca4511db8a..ecdd5b0c3b 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -74,6 +74,32 @@ export interface ReplayConfig { * by tests that do not need discovery. */ providers?: ReplayProviderConfig[] + /** + * Optional per-chunk pacing delay in milliseconds: each replayed chunk waits + * this long before yielding, so a downstream transport (e.g. the web SSE + * mux observed by a browser) sees genuinely incremental delivery. A realism + * knob only — correctness must never depend on it. Absent or `0` keeps + * today's synchronous burst yield. Must be a non-negative finite integer; + * aborting mid-wait cancels the stream like any other abort. + */ + paceMs?: number +} + +/** + * Handle returned by {@link installLlmReplay}: removal plus the end-of-run + * consumption check that turns silent fixture underruns (a scenario that + * issued fewer calls than recorded, or never bound a recorded child script) + * into a crisp diagnostic at teardown. + */ +export interface ReplayHandle { + /** Remove the registered adapter or waterfall listener (HMR safety). Freestanding closure — safe to destructure. */ + dispose(this: void): void + /** + * Throw unless every recorded script was bound to a live session and every + * bound cursor consumed its full entry list. Call at scenario teardown. + * Freestanding closure — safe to destructure. + */ + assertConsumed(this: void): void } /** @@ -277,12 +303,32 @@ class ReplayAdapter extends LlmAdapter { } } +/** + * Wait `paceMs` between chunk yields, aborting the wait (and the stream) the + * moment the signal fires — a paced replay must cancel as promptly as a burst + * one. + */ +function paceDelay(paceMs: number, signal: AbortSignal | undefined): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, paceMs) + const onAbort = (): void => { + clearTimeout(timer) + reject(new Error('aborted')) + } + signal?.addEventListener('abort', onAbort, { once: true }) + }) +} + /** Yield a recorded stream back, honoring abort like a real adapter. */ -async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable { +async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined, paceMs: number): AsyncIterable { switch (entry.kind) { case 'chunks': for (const chunk of entry.chunks) { if (signal?.aborted) throw new Error('aborted') + if (paceMs > 0) await paceDelay(paceMs, signal) yield chunk } return @@ -293,6 +339,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) // mid-stream STREAM_CLOSED after partial chunks). for (const chunk of entry.chunks) { if (signal?.aborted) throw new Error('aborted') + if (paceMs > 0) await paceDelay(paceMs, signal) yield chunk } throw new LlmError(entry.message, entry.code) @@ -319,14 +366,17 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * next ordered recorded script, then advances its own cursor synchronously at * invocation time; calls without `sessionId` share one anonymous session. A * non-empty provider catalog registers a routed replay adapter; otherwise a - * catch-all waterfall intercepts requests. Returns the effect disposer for - * HMR-safe removal. + * catch-all waterfall intercepts requests. * * @param ctx - the context whose LLM service receives the replay route or waterfall. * @param config - the resolved fixture paths (env-var defaulting is `apply`'s job). - * @returns the disposer that removes the registered adapter or listener. + * @returns the {@link ReplayHandle} carrying the disposer and the teardown consumption check. */ -export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { +export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHandle { + const paceMs = config.paceMs ?? 0 + if (!Number.isInteger(paceMs) || paceMs < 0) { + throw new Error(`llm-replay: paceMs must be a non-negative integer, got ${String(config.paceMs)}`) + } const scripts = loadSessionScripts(config) // Live-session → its bound script + cursor. A new live session id claims the // next not-yet-bound script (scripts are in bind order); `nextScript` is the @@ -370,14 +420,31 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } - yield* replayEntry(entry, options.signal) + yield* replayEntry(entry, options.signal, paceMs) })() } const providers = config.providers ?? [] - if (providers.length > 0) { - return ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay)) + const dispose = providers.length > 0 + ? ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay)) + : ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options)) + return { + dispose, + assertConsumed(): void { + const problems: string[] = [] + if (nextScript < scripts.length) { + problems.push(`${scripts.length - nextScript} recorded script(s) never bound to a live session`) + } + for (const [key, state] of bound) { + if (state.cursor < state.entries.length) { + const who = key === ANON ? 'the anonymous session' : `session ${key}` + problems.push(`${who} consumed ${state.cursor}/${state.entries.length} recorded call(s)`) + } + } + if (problems.length > 0) { + throw new Error(`llm-replay: fixture not fully consumed — ${problems.join('; ')}; the scenario drove fewer model calls than recorded`) + } + }, } - return ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options)) } export const name = 'llm-replay' @@ -397,6 +464,8 @@ export interface Config { childFiles?: string[] /** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */ providers?: ReplayProviderConfig[] + /** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */ + paceMs?: number } export function apply(ctx: Context, config: Config = {}): void { @@ -413,5 +482,6 @@ export function apply(ctx: Context, config: Config = {}): void { ...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {}, ...childFiles.length > 0 ? { childFiles } : {}, ...config.providers !== undefined ? { providers: config.providers } : {}, + ...config.paceMs !== undefined ? { paceMs: config.paceMs } : {}, }) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 14086db27f..b6b03aacbf 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -234,7 +234,7 @@ describe('installLlmReplay (through the real LlmService)', () => { writeLog(TEXT_CHUNKS) const ctx = new Context() await ctx.plugin(LlmService) - const dispose = installLlmReplay(ctx, { + const { dispose } = installLlmReplay(ctx, { file, providers: [ { @@ -429,6 +429,67 @@ describe('installLlmReplay (through the real LlmService)', () => { await iterator.next() await expect(iterator.next()).rejects.toThrow('aborted') }) + + it('rejects a paceMs that is not a non-negative integer', async () => { + writeLog(TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + expect(() => installLlmReplay(ctx, { file, paceMs: -1 })).toThrow(/paceMs/) + expect(() => installLlmReplay(ctx, { file, paceMs: 1.5 })).toThrow(/paceMs/) + }) + + it('paces chunk yields when paceMs is set (each chunk waits at least the pace)', async () => { + writeLog(TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, paceMs: 10 }) + const started = performance.now() + const chunks = await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) + expect(chunks).toEqual(TEXT_CHUNKS) + // N chunks × 10ms; allow generous scheduling slack, assert the floor only. + expect(performance.now() - started).toBeGreaterThanOrEqual(TEXT_CHUNKS.length * 10 - 5) + }) + + it('aborting DURING a pace wait cancels the stream promptly', async () => { + writeLog(TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, paceMs: 60_000 }) + const controller = new AbortController() + const pending = drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], signal: controller.signal })) + // Let the generator park inside the pace timer, then abort — the reject + // must come from the abort listener, not the (distant) timer. + await new Promise(r => setImmediate(r)) + controller.abort() + await expect(pending).rejects.toThrow('aborted') + }) + + it('assertConsumed passes only after every recorded call replayed', async () => { + writeLog(TEXT_CHUNKS, TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + const handle = installLlmReplay(ctx, { file }) + await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) + // One of two recorded calls consumed — the underrun must name the gap. + expect(() => { handle.assertConsumed() }).toThrow(/consumed 1\/2 recorded call/) + await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] })) + expect(() => { handle.assertConsumed() }).not.toThrow() + }) + + it('assertConsumed reports recorded scripts no live session ever bound', async () => { + writeLog(TEXT_CHUNKS) + const childFile = join(dir, 'session.1.jsonl') + writeFileSync(childFile, sessionJsonl( + TEXT_CHUNKS.map((chunk, i) => chunkEvent(i + 1, 1, 1, chunk)), + { id: 'child', createdAt: 10 }, + ), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + const handle = installLlmReplay(ctx, { file, childFiles: [childFile] }) + await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId: 'live-parent' as NonNullable })) + // The child script never bound: the scenario drove fewer sessions than recorded. + expect(() => { handle.assertConsumed() }).toThrow(/1 recorded script\(s\) never bound/) + }) }) describe('parseSessionHeader', () => { From 795af3174ed2f06d3adf6d572bcc0945ae04435b Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 19:21:32 +0800 Subject: [PATCH 15/70] 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 16/70] 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 17/70] 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 46b9a91e556437946b3356d2e5d6eed241cd57ef Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:49:02 +0800 Subject: [PATCH 18/70] =?UTF-8?q?test(web):=20keyless=20browser=20e2e=20la?= =?UTF-8?q?ne=20=E2=80=94=20replayed=20round=20trip=20+=20seeded=20cold=20?= =?UTF-8?q?resume?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/tests/harness.ts boots the real web assembly in-process (startHost llm:false -> installLlmReplay providers-mode -> mountWebPlugins -> startWebServer) under DSH_SNAPSHOT replay/record/refresh. Barrier stack: in-process turn/end -> agent.whenIdle (covers the persistence flush) -> browser settled-poll. Seeding goes through the real persistence API (semantic-checkpoint precedent); record harvests fixtures from live session memory and tokenizes {{sessionId}}/{{cwd}}; refresh is the sole golden writer. Console tripwires fail scenarios on reconnect/gap-repair self-healing; harness close asserts full replay-fixture consumption. Scenarios, each with fixtures recorded against THIS assembly via a live model run: replay-round-trip (real composer -> real bash echo -> settled markdown + aria golden + world-state event asserts) and seeded-history (cold sidebar list -> implicit resume on open -> history tool cards from the log, zero model calls). apps/web/tests are host-plane programs: excluded from the client-registered apps/web project, included in tsconfig.host.json (one program cannot hold both Context merge sides). --- apps/web/tests/harness.ts | 423 ++++++++++++++++++ apps/web/tests/replay-round-trip.e2e.ts | 109 +++++ apps/web/tests/seeded-history.e2e.ts | 105 +++++ .../snapshots/fresh-round-trip/session.jsonl | 97 ++++ .../snapshots/fresh-round-trip/ui.expected.md | 31 ++ .../tests/snapshots/seeded-history/seed.jsonl | 112 +++++ .../snapshots/seeded-history/ui.expected.md | 36 ++ apps/web/tsconfig.json | 9 + tsconfig.host.json | 4 + 9 files changed, 926 insertions(+) create mode 100644 apps/web/tests/harness.ts create mode 100644 apps/web/tests/replay-round-trip.e2e.ts create mode 100644 apps/web/tests/seeded-history.e2e.ts create mode 100644 apps/web/tests/snapshots/fresh-round-trip/session.jsonl create mode 100644 apps/web/tests/snapshots/fresh-round-trip/ui.expected.md create mode 100644 apps/web/tests/snapshots/seeded-history/seed.jsonl create mode 100644 apps/web/tests/snapshots/seeded-history/ui.expected.md diff --git a/apps/web/tests/harness.ts b/apps/web/tests/harness.ts new file mode 100644 index 0000000000..6f885da0c8 --- /dev/null +++ b/apps/web/tests/harness.ts @@ -0,0 +1,423 @@ +// Shared harness for the keyless browser e2e lane (Agent Note: +// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). +// Boots the REAL web assembly in-process from the exported production +// functions — startHost (bootHost spine) + mountWebPlugins + registry + +// startWebServer — so a real chromium exercises the real HTTP/SSE wire, +// apiproxy, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: +// replay (default, keyless: `llm: false` + dsh-llm-replay in providers mode), +// record (real DeepSeek adapter + key, harvests fixtures from live session +// memory), refresh (keyless replay that rewrites the committed goldens). +// +// Assembly divergence from `dsh web` (apps/cli/src/web.ts), deliberate: the +// shipped shell opts into sessionTitleLlm, whose fire-and-forget title call +// shares the session's replay cursor — nondeterministic ordering against the +// loop's own calls — so this lane keeps bootHost's disabled default and +// sidebar titles come from the deterministic fallback service. +import { existsSync, readFileSync } from 'node:fs' +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Page } from 'playwright' +import { expect } from 'vitest' +import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' +import { startHost, mountWebPlugins } from '@deepseek-ai/dsh-host-runtime' +import type { RunningHost } from '@deepseek-ai/dsh-host-runtime' +import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver' +import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { Context } from 'cordis' +import { DIST_INDEX, REPO_ROOT, requireDist } from './support.ts' + +/** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the ACP/TUI suites). */ +export type WebSnapshotMode = 'replay' | 'record' | 'refresh' + +/** + * Resolve and validate the lane's snapshot mode. + * @returns the active mode; unset/empty selects replay. + */ +export function webSnapshotMode(): WebSnapshotMode { + const value = process.env.DSH_SNAPSHOT + if (value === undefined || value === '' || value === 'replay') return 'replay' + if (value === 'record' || value === 'refresh') return value + throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) +} + +// Replay must run in providers mode (never catch-all): with `llm: false` no +// adapter exists, so a catch-all would leave resolveModelContext unroutable +// and compact-basic's post-step pressure check would warn every step. The +// published contextWindow keeps that pressure path provably inert for small +// fixtures. +const PROVIDERS = [{ id: 'deepseek', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', contextWindow: 128_000 }] }] + +// The shipped client roster (apps/cli/src/web.ts CLIENT_PACKAGES, sans the +// --dev HMR row). apps/web depends on every entry, so its URL anchors the +// Loader's bare-specifier resolution. +const CLIENT_PACKAGES = [ + '@deepseek-ai/dsh-client-connection', + '@deepseek-ai/dsh-client-runtime', + '@deepseek-ai/dsh-client-ui-theme', + '@deepseek-ai/dsh-client-i18n', + '@deepseek-ai/dsh-client-ui-layout', + '@deepseek-ai/dsh-client-ui-sidebar', + '@deepseek-ai/dsh-client-ui-conversation', + '@deepseek-ai/dsh-client-ui-question', + '@deepseek-ai/dsh-client-ui-trajectory', +] as const + +/** Repo-root .env → process.env for record mode (never overrides set vars); the smoke-real convention. */ +function loadRootEnv(): void { + const envPath = join(REPO_ROOT, '.env') + if (!existsSync(envPath)) return + for (const line of readFileSync(envPath, 'utf8').split('\n')) { + const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line.trim()) + if (m !== null && process.env[m[1]!] === undefined) process.env[m[1]!] = m[2] + } +} + +/** A booted web harness: real assembly, mode-selected model backend, temp world. */ +export interface WebHarness { + /** The active snapshot mode this harness booted under. */ + mode: WebSnapshotMode + /** Browser-facing origin (http://127.0.0.1:). */ + baseUrl: string + /** The running host (ctx is the documented in-process barrier seam). */ + host: RunningHost + /** Temp project directory sessions run in (bash/fs tool cwd). */ + workspaceCwd: string + /** Temp persistence root (seeded sessions land here through the real API). */ + persistenceRoot: string + /** Errors the web server reported asynchronously; assert empty at scenario end. */ + serverErrors: string[] + /** Await a settled turn end: in-process turn/end, then the agent's idle flip (which follows the persistence flush). */ + whenTurnSettled(timeoutMs?: number): Promise + /** Tear everything down; asserts the replay fixture was fully consumed first (replay/refresh). */ + close(): Promise +} + +/** Options for {@link launchWebHarness}. */ +export interface LaunchOptions { + /** + * Replay fixture (session.jsonl) served by dsh-llm-replay in replay/refresh + * modes; ignored in record mode (the real adapter answers). Omit for + * scenarios issuing no model calls — a stray stream then fails loud with + * NO_ADAPTER on the open seam. + */ + replayFixture?: string + /** Per-chunk replay pacing (ms) so the browser observes genuinely incremental SSE; replay/refresh only. */ + paceMs?: number +} + +/** + * Boot the real web assembly under the current snapshot mode. + * @param options - replay fixture selection and pacing. + * @returns the running harness. + */ +export async function launchWebHarness(options: LaunchOptions = {}): Promise { + requireDist() + const mode = webSnapshotMode() + if (mode === 'record') { + loadRootEnv() + if (process.env.DEEPSEEK_API_KEY === undefined || process.env.DEEPSEEK_API_KEY.length === 0) { + throw new Error('web e2e record mode needs DEEPSEEK_API_KEY (env or repo-root .env)') + } + } + const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')) + const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) + const serverErrors: string[] = [] + let host: RunningHost | undefined + let server: Awaited> | undefined + let replay: ReplayHandle | undefined + try { + host = await startHost({ + boot: { + persistenceRoot, + // Keep the request header free of ambient AGENTS.md content so + // recorded fixtures do not embed this repo's instructions. + workspaceContext: false, + cwd: workspaceCwd, + // Replay/refresh boot keyless with the llm seam open; record mounts + // the real adapter and performs real provider I/O. + ...(mode === 'record' ? {} : { llm: false as const }), + }, + }) + if (mode !== 'record' && options.replayFixture !== undefined) { + replay = installLlmReplay(host.ctx, { + file: options.replayFixture, + providers: PROVIDERS, + ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }), + }) + } + // Anchor at apps/cli exactly as `dsh web` does: that package declares + // every roster entry as a dependency, so the Loader's bare-specifier + // resolution and the registry's package.json resolver both work. + const anchor = pathToFileURL(join(REPO_ROOT, 'apps/cli/src/web.ts')).href + const mounted = await mountWebPlugins(host.ctx, CLIENT_PACKAGES, anchor) + const webPlugins = createHostWebPluginRegistry({ + ctx: host.ctx, + loader: mounted.loader, + resolvePkgJson: mounted.resolvePkgJson, + onError: (err: Error) => { serverErrors.push(String(err)) }, + }) + server = await startWebServer( + { host: '127.0.0.1', port: 0, distIndex: DIST_INDEX, apiHandler: host.handler, webPlugins }, + (err: Error) => { serverErrors.push(String(err)) }, + ) + } catch (error) { + await server?.close().catch(() => undefined) + await host?.dispose().catch(() => undefined) + await rm(workspaceCwd, { recursive: true, force: true }).catch(() => undefined) + await rm(persistenceRoot, { recursive: true, force: true }).catch(() => undefined) + throw error + } + const runningHost = host + const runningServer = server + const replayHandle = replay + + return { + mode, + baseUrl: `http://127.0.0.1:${server.port}`, + host, + workspaceCwd, + persistenceRoot, + serverErrors, + // Barrier stack: the in-process turn/end identifies the session, then + // agent.whenIdle() covers the persistence flush (the idle flip follows + // the flush), and the caller's browser settled-poll comes last because + // host completion strictly precedes render. + whenTurnSettled(timeoutMs = mode === 'record' ? 180_000 : 30_000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + off() + reject(new Error(`no turn/end within ${timeoutMs}ms`)) + }, timeoutMs) + const off = runningHost.ctx.on('session/event', (session: { id: SessionId }, event: SessionEvent) => { + if (event.type !== 'turn/end') return + clearTimeout(timer) + off() + const agent = runningHost.ctx.agents.get(session.id) + if (agent === undefined) { + reject(new Error(`turn/end for ${session.id} but no live agent`)) + return + } + agent.whenIdle().then(() => { resolve(session.id) }, reject) + }) + }) + }, + async close(): Promise { + const failures: unknown[] = [] + // Fixture-consumption check first, while the run's binding state is + // still authoritative — a scenario that drove fewer model calls than + // recorded fails here instead of drifting green. + try { + replayHandle?.assertConsumed() + } catch (error) { + failures.push(error) + } + await runningServer.close().catch((e: unknown) => failures.push(e)) + await runningHost.dispose().catch((e: unknown) => failures.push(e)) + await rm(workspaceCwd, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) + await rm(persistenceRoot, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) + if (failures.length > 0) throw new AggregateError(failures, 'web harness teardown failed') + }, + } +} + +/** + * Serialize a live session back to raw session-JSONL (header + events) — the + * in-memory record-mode harvest, so the on-disk zstd default never matters. + * Mirrors the TUI suite's rawSessionLog. + * @param session - the live session to serialize. + * @returns raw JSONL text ending in one newline. + */ +export function rawSessionLog(session: Session): string { + return [ + JSON.stringify({ type: 'session', ...session.header }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +/** + * Record-mode fixture write-back: harvest the live session, scrub request + * headers to {{system}}/{{tools}} (the web lane pins no header class — a + * deliberate deviation logged in the Agent Note's deferred work), tokenize + * the run-local session id and cwd ({{sessionId}}/{{cwd}}, the committed ACP + * fixture convention — re-records then diff only on real content), and write + * the committed fixture. + * @param harness - the record-mode harness. + * @param sessionId - the driven session. + * @param fixturePath - the committed session.jsonl / seed.jsonl target. + */ +export async function recordFixture(harness: WebHarness, sessionId: SessionId, fixturePath: string): Promise { + const agent = harness.host.ctx.agents.get(sessionId) + if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) + const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) + .split(sessionId).join('{{sessionId}}') + .split(harness.workspaceCwd).join('{{cwd}}') + await writeFile(fixturePath, tokenized) +} + +/** + * The user prompts recorded in a fixture, in order — the single source tying + * spec drive steps to recorded reality so script and fixture cannot drift. + * @param fixtureText - raw session.jsonl contents. + * @returns the recorded user prompt texts. + */ +export function fixtureUserPrompts(fixtureText: string): string[] { + return parseSessionLog(fixtureText).flatMap((event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] + const text = event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + return text.length > 0 ? [text] : [] + }) +} + +/** + * Seed a recorded session fixture into the harness's persistence root through + * the REAL backend API (throwaway Context + SessionStore + JSONL plugin — the + * semantic-checkpoint precedent), never raw file writes: no knowledge of + * bucket hashing, filename encoding, or compression, and malformed shapes + * fail loud at seed time. The fixture's recorded cwd is rewritten to the + * harness workspace so header/path identity and event payload paths agree. + * @param harness - the target harness. + * @param fixtureText - raw recorded session.jsonl contents. + * @param id - the seeded session id (stable for deterministic goldens). + * @returns the seeded id. + */ +export async function seedSession(harness: WebHarness, fixtureText: string, id: string): Promise { + // Committed fixtures tokenize run-local identity ({{sessionId}}/{{cwd}}, + // written by recordFixture); realize both for this world before parsing. + const realized = fixtureText + .split('{{sessionId}}').join(id) + .split('{{cwd}}').join(harness.workspaceCwd) + const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd + const rewritten = fixtureCwd === undefined + ? realized + : realized.split(fixtureCwd).join(harness.workspaceCwd) + const events = parseSessionLog(rewritten) + if (events.length === 0) throw new Error('seed fixture has no events') + const last = events[events.length - 1]! + // An open final turn would be mutated by resume's crash repair on first + // open; a committed seed must be a closed recording. + if (last.type !== 'turn/end') throw new Error(`seed fixture must end in turn/end, got ${last.type}`) + const meta: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id: SessionId(id), + createdAt: Date.now() - 60_000, + cwd: harness.workspaceCwd, + delegationDepth: 0, + } + const ctx = new Context() + try { + await ctx.plugin(SessionStore) + // Same root as the host with the plugin's own default compression, so the + // host's directory-scan list() sees one consistent encoding. + await ctx.plugin(SessionPersistenceJsonl, { root: harness.persistenceRoot }) + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(meta.id, events) + // Deterministic sidebar order: cold summaries take updatedAt from mtime. + const located = ctx.sessionPersistence.locate(meta) + if (located !== undefined) { + const backdated = new Date(meta.createdAt) + await utimes(located.path, backdated, backdated) + } + } finally { + await ctx.fiber.dispose() + } + return meta.id +} + +/** + * Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration + * volatility collapse to stable tokens. + * @param snapshot - raw ariaSnapshot text. + * @param workspaceCwd - the harness workspace (basename doubles as the header breadcrumb). + * @returns tokenized snapshot text. + */ +export function normalizeAria(snapshot: string, workspaceCwd: string): string { + // The header breadcrumb renders the workspace's basename, not the full + // path, so both spellings must collapse to the token. + const base = workspaceCwd.split('/').pop()! + return snapshot + .split(workspaceCwd).join('{{cwd}}') + .split(base).join('{{workspace}}') + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '{{uuid}}') + .replace(/\b\d+(?:\.\d+)?(?:ms|s|秒)\b/g, '{{duration}}') +} + +/** + * Capture the region's aria snapshot at a settled milestone: poll until two + * consecutive normalized captures are equal — a single-shot capture races the + * last React commits. + * @param page - the page under test. + * @param selector - the region locator selector. + * @param workspaceCwd - normalization input. + * @returns the stable normalized snapshot. + */ +export async function captureStableAria(page: Page, selector: string, workspaceCwd: string): Promise { + const region = page.locator(selector).first() + let previous = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + await expect.poll(async () => { + const current = normalizeAria(await region.ariaSnapshot(), workspaceCwd) + const stable = current === previous + previous = current + return stable + }, { timeout: 5_000, message: 'aria snapshot did not stabilize' }).toBe(true) + return previous +} + +/** + * Compare a normalized golden, or rewrite it under refresh. Refresh is the + * ONLY writer: a missing golden in replay mode fails with the healing command + * instead of silently self-bootstrapping. + * @param goldenPath - the committed ui.expected.md path. + * @param actual - the stable normalized snapshot. + * @param mode - the active snapshot mode. + */ +export async function compareOrRefreshGolden(goldenPath: string, actual: string, mode: WebSnapshotMode): Promise { + const payload = `${actual}\n` + if (mode === 'refresh') { + await writeFile(goldenPath, payload) + return + } + if (!existsSync(goldenPath)) { + throw new Error(`missing golden ${goldenPath} — run DSH_SNAPSHOT=refresh pnpm run test:web to generate it`) + } + expect(payload).toBe(await readFile(goldenPath, 'utf8')) +} + +/** + * Fixture-inventory guard (the TUI afterAll shape): the scenario directory + * holds exactly the expected files and every committed JSONL is a scrub + * fixed-point (no request-header bulk escaped the record write-back). + * @param dir - the scenario snapshot directory. + * @param expected - the exact expected file inventory. + */ +export async function assertFixtureInventory(dir: string, expected: string[]): Promise { + const entries = (await readdir(dir)).sort() + expect(entries).toEqual([...expected].sort()) + for (const entry of entries.filter(name => name.endsWith('.jsonl'))) { + const content = await readFile(join(dir, entry), 'utf8') + expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content) + } +} + +/** + * Console tripwires: reconnect/gap-repair self-healing or a pageerror must + * fail the scenario, not mask a dead wire behind eventual consistency. + * @param page - the page under test. + * @returns live warning/pageerror collectors to assert empty at scenario end. + */ +export function watchConsole(page: Page): { warnings: string[]; pageErrors: string[] } { + const warnings: string[] = [] + const pageErrors: string[] = [] + page.on('console', (message) => { + const text = message.text() + if (/connection lost|gap repair|discontinuous/i.test(text)) warnings.push(text) + }) + page.on('pageerror', (error) => { pageErrors.push(String(error)) }) + return { warnings, pageErrors } +} diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts new file mode 100644 index 0000000000..faf1a1f6a8 --- /dev/null +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -0,0 +1,109 @@ +// Web e2e scenario: fresh round trip. A real chromium types a prompt into the +// real composer; the wire, apiproxy, agent loop, and the REAL bash tool (echo +// in the temp workspace) all run; the model seam is dsh-llm-replay (keyless) +// or the live adapter (record). Drive steps run in every mode and wait only +// on generic completion (whenTurnSettled — never model-content selectors, so +// record cannot hang on a live model answering differently); assertion steps +// run in replay/refresh only. Settled states only — streaming incrementality +// is asserted from the persisted assistant/chunk events, not transient DOM. +// Record: DSH_SNAPSHOT=record rewrites session.jsonl, then a keyless +// DSH_SNAPSHOT=refresh regenerates ui.expected.md. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebHarness, recordFixture, watchConsole, webSnapshotMode, type WebHarness, +} from './harness.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/fresh-round-trip/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() + +// The scenario's one drive prompt. Record sends it; replay asserts the +// committed fixture recorded exactly it, so drive script and fixture cannot +// drift apart. +const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' + +describe('web e2e: fresh round trip through the real assembly', () => { + let harness: WebHarness + let browser: Browser + let page: Page + let tripwire: ReturnType + const sessionEvents: SessionEvent[] = [] + + beforeAll(async () => { + harness = await launchWebHarness({ + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), + }) + harness.host.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(harness.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await harness?.close() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + // Arm the host-side settled barrier BEFORE the send click. + const settled = harness.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(harness, sessionId, FIXTURE) + } + }, 200_000) + + it.skipIf(MODE === 'record')('rendered the settled turn: markdown, tool row, composer restore', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-settled')) + // Browser settled-poll after host completion (host strictly precedes render). + await page.locator('[data-streaming="true"]').waitFor({ state: 'detached', timeout: 15_000 }).catch(() => { + // Chunks may coalesce into one commit; a never-mounted streaming node is + // legal — the chunk-event assertions below carry incrementality. + }) + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) + // World state, not self-report: bash really ran and the turn closed clean. + const toolCalls = sessionEvents.filter(e => e.type === 'tool/call') + expect(toolCalls.map(e => (e as SessionEvent & { data: { name: string } }).data.name)).toContain('bash') + const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') + expect(turnEnds.length).toBe(1) + expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') + // The persisted chunk events are the authoritative incrementality proof. + expect(sessionEvents.filter(e => e.type === 'assistant/chunk').length).toBeGreaterThan(10) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the conversation aria golden with stable anchors', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-aria')) + // Anchor assertions survive a semantics-preserving component rewrite even + // while the whole-region golden churns. + await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true) + expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + expect(harness.serverErrors).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts new file mode 100644 index 0000000000..734c0848c4 --- /dev/null +++ b/apps/web/tests/seeded-history.e2e.ts @@ -0,0 +1,105 @@ +// Web e2e scenario: seeded history. A recorded session seeded cold through +// the REAL persistence API renders purely from the log — the surface nothing +// else covers: sidebar cold listing, the implicit resume/attach inside the +// history RPC, history-page tool views, and the client fold of historical +// events — with ZERO model calls in replay (no replay fixture; a stray stream +// fails loud on the open llm seam). The seed is a recorded fixture under the +// same record discipline as every other: DSH_SNAPSHOT=record drives the turn +// live through the composer (real read tool against seeded workspace files) +// and harvests seed.jsonl; replay/refresh seed it cold and only render. +import { readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { join } from 'node:path' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebHarness, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebHarness, +} from './harness.ts' +import { saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const UI_EXPECTED = fileURLToPath(new URL('./snapshots/seeded-history/ui.expected.md', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'seeded-history-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: seeded history renders through cold resume', () => { + let harness: WebHarness + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + harness = await launchWebHarness({}) + // The read-tool targets exist in both modes: record needs them for the + // live turn; replay's seeded log carries their recorded contents but the + // workspace stays consistent for any user poking the harness. + await writeFile(join(harness.workspaceCwd, 'a.txt'), 'alpha\n') + await writeFile(join(harness.workspaceCwd, 'b.txt'), 'beta\n') + if (MODE !== 'record') { + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) + await seedSession(harness, raw, SEED_ID) + } + browser = await chromium.launch() + page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) + tripwire = watchConsole(page) + await page.goto(harness.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await harness?.close() + }) + + it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record')) + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + const settled = harness.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + await recordFixture(harness, sessionId, SEED) + }, 200_000) + + it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-history')) + // The sidebar tree collapses workspace groups by default: click the group + // row (treeitem 0) to expand, then the revealed session row. + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + // Settled barrier for history: the recorded final assistant text renders. + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + // Tool cards render from logged tool/call + tool/result alone (views are + // host-recomputed per page; the generic card is the documented default). + const toolRows = page.locator('[data-variant], [data-sample]') + await expect.poll(() => toolRows.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(2) + expect(await page.getByText('a.txt', { exact: false }).count()).toBeGreaterThan(0) + }, 60_000) + + it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { + // No replay fixture was installed and the llm seam is open — any stray + // stream would have failed the turn loudly. Cleanliness pins the wire. + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + expect(harness.serverErrors).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl new file mode 100644 index 0000000000..d9cc109c60 --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -0,0 +1,97 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784893539564,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784893539588,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"4962b8d4-0422-4f81-8187-642e3e6bab78"}}}} +{"type":"user/message","seq":1,"time":1784893539589,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"4962b8d4-0422-4f81-8187-642e3e6bab78"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784893539592,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784893539657,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784893539658,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784893540271,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784893540271,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1784893540366,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1784893540396,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":12,"time":1784893540397,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1784893540421,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":14,"time":1784893540447,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":15,"time":1784893540447,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":16,"time":1784893540448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":17,"time":1784893540448,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1784893540475,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":21,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":22,"time":1784893540476,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1784893540565,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1784893540566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1784893540591,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1784893540592,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" WEB"}}} +{"type":"assistant/chunk","seq":33,"time":1784893540621,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_E"}}} +{"type":"assistant/chunk","seq":34,"time":1784893540651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":35,"time":1784893540651,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":36,"time":1784893540652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":37,"time":1784893540652,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1784893540680,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":39,"time":1784893540680,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1784893540709,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":41,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":43,"time":1784893540710,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1784893540738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"Print"}}} +{"type":"assistant/chunk","seq":45,"time":1784893540738,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" WEB"}}} +{"type":"assistant/chunk","seq":46,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_E"}}} +{"type":"assistant/chunk","seq":47,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":48,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":49,"time":1784893540768,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":50,"time":1784893540769,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":51,"time":1784893540797,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":52,"time":1784893540801,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":53,"time":1784893540826,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":54,"time":1784893540859,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":55,"time":1784893540859,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}}}} +{"type":"assistant/chunk","seq":56,"time":1784893540859,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":7802,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":57,"time":1784893540860,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":58,"time":1784893540863,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and reply with \"DONE\"."},{"type":"tool-call","id":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":7802,"outputTokens":88,"cacheReadTokens":0,"reasoningTokens":17}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57],"surfaceOp":"append"} +{"type":"tool/call","seq":59,"time":1784893540864,"data":{"turn":1,"step":1,"callId":"call_00_yxp0l3itFAMPCLKhz7EU1205","name":"bash","arguments":"{\"command\": \"echo WEB_E2E_OK\", \"description\": \"Print WEB_E2E_OK to stdout\"}"}} +{"type":"tool/result","seq":60,"time":1784893540878,"data":{"turn":1,"step":1,"callId":"call_00_yxp0l3itFAMPCLKhz7EU1205","content":[{"type":"text","text":"WEB_E2E_OK\n"}],"isError":false},"sourceEventSeqs":[59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1784893540881,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":62,"time":1784893540881,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":63,"time":1784893541457,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":64,"time":1784893541457,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":65,"time":1784893541545,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":66,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" executed"}}} +{"type":"assistant/chunk","seq":67,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":68,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":69,"time":1784893541574,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" printed"}}} +{"type":"assistant/chunk","seq":70,"time":1784893541603,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":71,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WEB"}}} +{"type":"assistant/chunk","seq":72,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_E"}}} +{"type":"assistant/chunk","seq":73,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":74,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"E"}}} +{"type":"assistant/chunk","seq":75,"time":1784893541604,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":76,"time":1784893541633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":77,"time":1784893541633,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1784893541675,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":79,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":80,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":81,"time":1784893541676,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":82,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":83,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":84,"time":1784893541694,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":85,"time":1784893541695,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":86,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":87,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":88,"time":1784893541718,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":89,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":90,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":91,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":228,"outputTokens":25,"cacheReadTokens":7680,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":92,"time":1784893541719,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1784893541720,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":228,"outputTokens":25,"cacheReadTokens":7680,"reasoningTokens":22}},"sourceEventSeqs":[63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1784893541720,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":95,"time":1784893541721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md new file mode 100644 index 0000000000..4464843ce6 --- /dev/null +++ b/apps/web/tests/snapshots/fresh-round-trip/ui.expected.md @@ -0,0 +1,31 @@ +- banner: + - navigation "会话层级": + - button "Use the bash tool to" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." +- button "Think The user wants me to run a simple bash command and reply with \"DONE\".": + - img + - text: Think The user wants me to run a simple bash command and reply with "DONE". +- text: Print WEB_E2E_OK to stdout +- button "Think The command executed successfully and printed \"WEB_E2E_OK\". I should now reply with \"DONE\".": + - img + - text: Think The command executed successfully and printed "WEB_E2E_OK". I should now reply with "DONE". +- paragraph: DONE +- text: cache hit 49% · 15,823 tokens · 1 turns · 2 steps +- textbox "输入消息,Enter 发送,Shift+Enter 换行" +- button "添加": + - img +- combobox "Plan mode": + - option "Plan" [selected] + - option "Agent" +- combobox "Access mode": + - option "Read-only" [selected] + - option "Read-write" +- combobox "Model": + - option "DeepSeek-V4-Pro High" [selected] + - option "DeepSeek-V4-Pro" +- button "发送" [disabled] diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl new file mode 100644 index 0000000000..a27f182e32 --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -0,0 +1,112 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784893580342,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1784893580362,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"ab867856-cd1a-4270-8cee-c240076c58e9"}}}} +{"type":"user/message","seq":1,"time":1784893580363,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"ab867856-cd1a-4270-8cee-c240076c58e9"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1784893580365,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1784893580420,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1784893580421,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1784893581003,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":6,"time":1784893581003,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":7,"time":1784893581092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":8,"time":1784893581107,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":9,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":10,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":11,"time":1784893581108,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":12,"time":1784893581135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":13,"time":1784893581135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":14,"time":1784893581136,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1784893581161,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} +{"type":"assistant/chunk","seq":16,"time":1784893581162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":17,"time":1784893581162,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1784893581189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":19,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":20,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":21,"time":1784893581190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":22,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":23,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":24,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":25,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":26,"time":1784893581217,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":27,"time":1784893581258,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":28,"time":1784893581259,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} +{"type":"assistant/chunk","seq":29,"time":1784893581259,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":30,"time":1784893581324,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":31,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":32,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":33,"time":1784893581325,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":34,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":35,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":36,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1784893581351,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":38,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"a"}}} +{"type":"assistant/chunk","seq":40,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":41,"time":1784893581378,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1784893581404,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":43,"time":1784893581462,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":2,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":44,"time":1784893581462,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":45,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":46,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":47,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"file"}}} +{"type":"assistant/chunk","seq":48,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"_path"}}} +{"type":"assistant/chunk","seq":49,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1784893581486,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":51,"time":1784893581513,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":52,"time":1784893581513,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"b"}}} +{"type":"assistant/chunk","seq":53,"time":1784893581514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":".txt"}}} +{"type":"assistant/chunk","seq":54,"time":1784893581514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":55,"time":1784893581539,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":2,"id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":56,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with DONE. Let me read both files."}}}} +{"type":"assistant/chunk","seq":57,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":58,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":2,"block":{"type":"tool-call","id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":59,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":124,"outputTokens":100,"cacheReadTokens":7680,"reasoningTokens":24}}}} +{"type":"assistant/chunk","seq":60,"time":1784893581597,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":61,"time":1784893581601,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to read a.txt and b.txt, then reply with DONE. Let me read both files."},{"type":"tool-call","id":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"},{"type":"tool-call","id":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":124,"outputTokens":100,"cacheReadTokens":7680,"reasoningTokens":24}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"tool/call","seq":62,"time":1784893581602,"data":{"turn":1,"step":1,"callId":"call_00_8bQPkq98ZAzRTEJA6XM38538","name":"read","arguments":"{\"file_path\": \"a.txt\"}"}} +{"type":"tool/call","seq":63,"time":1784893581604,"data":{"turn":1,"step":1,"callId":"call_01_5FMIBJA9HHyktFY8KSlk9459","name":"read","arguments":"{\"file_path\": \"b.txt\"}"}} +{"type":"tool/result","seq":64,"time":1784893581608,"data":{"turn":1,"step":1,"callId":"call_00_8bQPkq98ZAzRTEJA6XM38538","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[62],"surfaceOp":"append"} +{"type":"tool/result","seq":65,"time":1784893581609,"data":{"turn":1,"step":1,"callId":"call_01_5FMIBJA9HHyktFY8KSlk9459","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[63],"surfaceOp":"append"} +{"type":"step/end","seq":66,"time":1784893581611,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":67,"time":1784893581611,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":68,"time":1784893582137,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":69,"time":1784893582137,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":70,"time":1784893582257,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" files"}}} +{"type":"assistant/chunk","seq":71,"time":1784893582259,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":72,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":73,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" read"}}} +{"type":"assistant/chunk","seq":74,"time":1784893582260,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":75,"time":1784893582277,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":76,"time":1784893582303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":77,"time":1784893582303,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":78,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":79,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"alpha"}}} +{"type":"assistant/chunk","seq":80,"time":1784893582304,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":81,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":82,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" b"}}} +{"type":"assistant/chunk","seq":83,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".txt"}}} +{"type":"assistant/chunk","seq":84,"time":1784893582330,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" contains"}}} +{"type":"assistant/chunk","seq":85,"time":1784893582331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":86,"time":1784893582331,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"beta"}}} +{"type":"assistant/chunk","seq":87,"time":1784893582356,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":88,"time":1784893582357,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":89,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":90,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":91,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":92,"time":1784893582384,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":93,"time":1784893582385,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":94,"time":1784893582411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":95,"time":1784893582411,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":96,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":97,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":98,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" D"}}} +{"type":"assistant/chunk","seq":99,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":100,"time":1784893582438,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":101,"time":1784893582467,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":102,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":103,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":104,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". Now I just need to reply with the single word DONE."}}}} +{"type":"assistant/chunk","seq":105,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":106,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":340,"outputTokens":35,"cacheReadTokens":7680,"reasoningTokens":32}}}} +{"type":"assistant/chunk","seq":107,"time":1784893582468,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":108,"time":1784893582469,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". Now I just need to reply with the single word DONE."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":340,"outputTokens":35,"cacheReadTokens":7680,"reasoningTokens":32}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107],"surfaceOp":"append"} +{"type":"step/end","seq":109,"time":1784893582470,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":110,"time":1784893582470,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/seeded-history/ui.expected.md b/apps/web/tests/snapshots/seeded-history/ui.expected.md new file mode 100644 index 0000000000..07f5b15b2d --- /dev/null +++ b/apps/web/tests/snapshots/seeded-history/ui.expected.md @@ -0,0 +1,36 @@ +- banner: + - navigation "会话层级": + - button "Use the read tool twice" [disabled] + - text: · 1 turns + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" + - tab "Waterfall" +- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop." +- button "Think The user wants me to read a.txt and b.txt, then reply with DONE. Let me read both files.": + - img + - text: Think The user wants me to read a.txt and b.txt, then reply with DONE. Let me read both files. +- button: + - img +- text: Read a.txt +- button: + - img +- text: Read b.txt +- button "Think Both files have been read. a.txt contains \"alpha\" and b.txt contains \"beta\". Now I just need to reply with the single word DONE.": + - img + - text: Think Both files have been read. a.txt contains "alpha" and b.txt contains "beta". Now I just need to reply with the single word DONE. +- paragraph: DONE +- text: cache hit 97% · 15,959 tokens · 1 turns · 2 steps +- textbox "输入消息,Enter 发送,Shift+Enter 换行" +- button "添加": + - img +- combobox "Plan mode": + - option "Plan" [selected] + - option "Agent" +- combobox "Access mode": + - option "Read-only" [selected] + - option "Read-write" +- combobox "Model": + - option "DeepSeek-V4-Pro High" [selected] + - option "DeepSeek-V4-Pro" +- button "发送" [disabled] diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 998996304e..514cbe4d57 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -17,6 +17,15 @@ "src", "tests" ], + // The web e2e lane (harness + replay specs) boots the host spine and reads + // its Context merges — host-plane programs, checked in tsconfig.host.json; + // this client-registered project must not also hold them (one program + // cannot see both sides of the cordis Context merges). + "exclude": [ + "tests/harness.ts", + "tests/replay-round-trip.e2e.ts", + "tests/seeded-history.e2e.ts" + ], "references": [ { "path": "../../packages/client/web" diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..a5f48ed22d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,6 +8,10 @@ "rewriteRelativeImportExtensions": false }, "include": [ + "apps/web/tests/harness.ts", + "apps/web/tests/support.ts", + "apps/web/tests/replay-round-trip.e2e.ts", + "apps/web/tests/seeded-history.e2e.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", "examples/*/tests/**/*.ts", From 2cd37e276518ef04489f5ddc0e08574827a9f3f8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:57:27 +0800 Subject: [PATCH 19/70] docs(testing): web e2e lane docs + Agent Note to implemented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit testing.md gains the web browser snapshot tier entry (divergent DSH_SNAPSHOT=... test:web commands) and names apps/web/tests/snapshots/ as the web surface's snapshot home. The GUI testing note's tier map and lane map gain the e2e scenarios (both languages, pair re-recorded) and drop the stale verify-session-real references (those scripts left with the missions/ tree). packages/client/AGENTS.md check ladder covers the wire-carriage trigger and refresh/record commands. acp-snapshot README stops claiming the whole package is ACP-specific — its normalizers are transport-neutral with three consumers now. vitest.web.config.ts header carries TODO(ci-browser) with the staged-reversal pointer. The design-study Agent Note moves proposed/ -> implemented/ rewritten in present tense: all review decisions recorded (llm:false seam over the placeholder-key hack, providers-mode replay, whenIdle barrier stack, single aria golden + anchors, TUI-style inline modes over a suite factory, scrub-only header stance, CI deferral) with re-entry triggers under Deferred. --- .../2026-07-20-gui-testing-system.i18n.yaml | 4 +- .../process/2026-07-20-gui-testing-system.md | 8 +- .../2026-07-20-gui-testing-system.zh.md | 8 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 88 ++++++++++++++++++ .../2026-07-24-web-gui-browser-e2e-lane.md | 92 ------------------- apps/web/tests/harness.ts | 10 +- docs/testing.md | 3 +- packages/client/AGENTS.md | 2 +- packages/support/acp-snapshot/README.md | 2 +- vitest.web.config.ts | 11 ++- 10 files changed, 114 insertions(+), 114 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md delete mode 100644 .agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index b21353698c..ca443d7a16 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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-gui-testing-system.md: e42dafcdf37e48475e7d420eaad9600e6c20891c -2026-07-20-gui-testing-system.zh.md: e4ef6246e59e0ad6c0a3070a38c546f964e347fa +2026-07-20-gui-testing-system.md: b261bd2c84a628ab6fcdc29c59cf36a7b2428a76 +2026-07-20-gui-testing-system.zh.md: ecb8634695bd05359e6b590a825a4ad3604003b1 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index e42dafcdf3..b261bd2c84 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane replays recorded session fixtures through the real in-process web assembly (`llm: false` + dsh-llm-replay) against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. @@ -33,15 +33,15 @@ Inter-tier discipline: **each tier tests its own layer, upper tiers never re-tes |---|---|---|---| | Baseline | `pnpm run test:gui` | Tier 1+2 vitest (`packages/client packages/host`), seconds-fast, no browser, no server | Casually, after touching any GUI source | | Semantic snapshot | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | Keyless assembled-application semantics plus the repo's transport-specific expected outputs | After a human-visible GUI change; before delivery | -| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 two-level smoke (fixture level + real-host level self-skip) | After touching the build surface/boot/carriage; before delivery | +| Browser end-to-end | `pnpm run test:web` | Rebuilds the front-end dist first, then runs the tier-3 browser set: the two-level smoke (fixture level + real-host level self-skip) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=record`/`refresh` re-record fixtures / rewrite goldens) | After touching the build surface/boot/carriage; before delivery | | Gate | `pnpm run test:coverage` | The repo-wide gate (host and client GUI packages included, except annotated browser-grade exclusions) | The PR window | **Division of labor between the browser scripts and vitest**: Playwright owns browser/carrier black-box regression and long sequential user journeys; ordinary vitest owns data-layer semantics such as reference stability, timing, and wire shapes; snapshot vitest owns stable app-level semantic output through the built composition. These lanes complement each other rather than duplicating assertions. ## Anti-regression discipline -- **Every bug fix pins an assertion**: a browser-visible bug is pinned into the regression section of its owning verify script (one pin = one report line); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense). -- **All-green on fixture is not done, the real host must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run `verify-session-real`. +- **Every bug fix pins an assertion**: a browser-visible bug is pinned into its owning browser spec (smoke or e2e scenario); a data-layer bug is pinned into the matching spec (precedent: the res-close misjudgment pinned in the webserver bridge suite — pure Node, reproduces in seconds, no longer needs the 12s browser sentinel as the only defense). +- **All-green on fixture is not done, the real wire must pass too**: what the fixture short-circuits is exactly the wire carriage chain (node:http bridge close semantics, real network timing); both empirically confirmed bugs hid there. Changes touching connection/bridge/handler/SSE must run the browser lane (`pnpm run test:web`) — its keyless e2e scenarios drive the real HTTP/SSE carriage, and the with-key real-host smoke remains the live-model complement. - The code-on-disk-is-the-answer reconciliation workflow: when a behavior change lands and turns existing cases red, reconcile on the spot (fix the test or fix the code, with the RFC/contract as arbiter); no red left hanging. ## Consequences diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index e4ef6246e5..ecb8634695 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过 | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道把录制的会话 fixture 通过真实进程内 web 组装(`llm: false` + dsh-llm-replay)回放,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | 层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 @@ -33,15 +33,15 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 基础 | `pnpm run test:gui` | 1+2 层 vitest(`packages/client packages/host`),秒级、无浏览器无 server | 改 GUI 任意源码后随手跑 | | 语义快照 | `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot` | 无需密钥的组装应用语义,以及仓库按传输形态划分的预期输出 | 用户可见的 GUI 变更后;交付前 | -| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层双级 smoke(fixture 级 + 真 host 级 self-skip) | 改构建面/boot/承载后;交付前 | +| 浏览器端到端 | `pnpm run test:web` | 先重建前端 dist,再跑 3 层浏览器全集:双级 smoke(fixture 级 + 真 host 级 self-skip)加上无密钥回放 e2e 场景(`DSH_SNAPSHOT=record`/`refresh` 重录 fixture / 重写期望输出) | 改构建面/boot/承载后;交付前 | | 门禁 | `pnpm run test:coverage` | 全仓 gate(host 与 client GUI 包均纳入,仅排除带注释的浏览器级例外) | PR 窗口 | **浏览器脚本与 vitest 的分工**:Playwright 负责浏览器/承载层黑盒回归和较长的连续用户操作流程;普通 vitest 负责引用稳定性、时序和 wire 结构等数据层语义;快照 vitest 通过构建后的组合负责稳定的应用层语义输出。这些车道彼此互补,而不重复断言。 ## 防回归纪律 -- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属 verify 脚本的回归节(一钉一行 report);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。 -- **fixture 全绿不算完,真 host 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,`verify-session-real` 必跑。 +- **修一个 bug 钉一条断言**:浏览器可见的 bug 钉进所属浏览器 spec(smoke 或 e2e 场景);数据层 bug 钉进对应 spec(先例:res-close 误判钉在 webserver 桥 suite——纯 Node 秒级复现,不再需要 12s 浏览器哨兵作唯一防线)。 +- **fixture 全绿不算完,真 wire 也要过**:fixture 短路的恰是 wire 承载链(node:http 桥 close 语义、真网络时序),两次实证 bug 都藏在那里。改动触及连接/桥/handler/SSE 的,浏览器车道(`pnpm run test:web`)必跑——其无密钥 e2e 场景驱动真实 HTTP/SSE 承载,带密钥的真 host smoke 仍是真模型侧的补充。 - 落盘代码即答案的对表工作流:行为改动落盘打红既有用例时,当场对表校准(改测试还是改代码以 RFC/契约为裁),不留悬红。 ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md new file mode 100644 index 0000000000..fddd1e9a0c --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -0,0 +1,88 @@ +# Agent Note: Keyless browser e2e lane for the web GUI + +Status: implemented + +## Problem + +The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → `bootHost`'s agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. + +## Decision + +`pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web assembly, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are the `BootHostOptions.llm` seam and two additive `dsh-llm-replay` surfaces. + +### Harness: `apps/web/tests/harness.ts` + +A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. + +`launchWebHarness()` boots the real web assembly in-process from the exported production functions — `startHost({ boot: { …, llm: false } })`, `installLlmReplay(host.ctx, { file, providers, paceMs })`, `mountWebPlugins(host.ctx, roster, anchor)`, `createHostWebPluginRegistry`, `startWebServer({ port: 0, … })`. This is the web analog of the TUI suite mounting the production bundle in-process ([TUI snapshots](2026-07-18-tui-terminal-state-snapshots.md)): the real entry boundary (`dsh web` bin arg-parsing, dist resolution) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`, and the web surface has no `cordis.yml` to bypass — assembly is written in the app per the [GUI layering decision](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md), a ruling this lane's design review explicitly reaffirmed (Loader-izing `dsh web` was declined; it would be its own proposal). Two deliberate assembly divergences from the `dsh web` shell, noted in the harness header: `workspaceContext: false` (recorded fixtures must not embed this repo's AGENTS.md) and `sessionTitleLlm` left at bootHost's disabled default (its fire-and-forget title call would share the session's replay cursor nondeterministically). + +The `llm: false` seam is the reviewed resolution of the keyless-boot question: `'deepseek' | false` on `BootHostOptions`, matching the `workspaceContext: Config | false` shape, with `RunningHost.ctx` JSDoc naming "filling a deliberately-open capability seam" as its third sanctioned use. Replay runs in providers-catalog mode with a published `contextWindow` (the TUI `PROVIDERS` shape), never catch-all: with no adapter registered, catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert. + +`seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair). + +### Determinism rules + +The barrier stack for a prompted turn, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible); (3) any log harvest after `host.dispose()`. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open). + +No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. + +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Harness `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text. + +### Expected outputs + +One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride `host.ctx` session events inline (which tools ran, `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. + +The typecheck plane split is structural: `apps/web/tests/{harness,support,replay-round-trip.e2e,seeded-history.e2e}.ts` are host-plane programs (they boot the host spine), so they are excluded from the client-registered `apps/web` project and included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. + +### Modes and fixtures + +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. + +### Scenarios + +1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (bash `tool/call`, completed `turn/end`, >10 chunk events). +2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. + +### CI stance + +The lane ships gate-exempt inside `pnpm run test:web`, exactly as that config's header records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise in the [GUI testing note](../process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from there, staged as a non-required job first with measured promotion criteria (consecutive green runs, wall time, zero-retry flake budget, runner browser-cache strategy). `TODO(ci-browser)` marks the seam. Scenarios are POSIX-oriented (the lane is not in the Windows matrix). + +## Prior art + +Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural. + +## Alternatives considered + +**Browser-network SSE interception (`page.route`).** Rejected: `route.fulfill` cannot stream, so incremental token rendering is unexercisable and the server-side SSE/backpressure/close path — where both confirmed P0s hid — goes untested. + +**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job. + +**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction. + +**Placeholder `DEEPSEEK_API_KEY` + replay interception instead of the `llm: false` seam.** Rejected despite zero product change and two in-tree precedents: it satisfies `llm-deepseek`'s fail-loud key check with a lie and leaves a dead adapter mounted-but-intercepted; the seam matches an existing option shape and fails loud at the earliest resolvable point. + +**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios a factory generalizes from one consumer while the genuinely shared logic is already exported from gated packages. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. + +**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on `host.ctx` events keep the world-verification duty. + +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-mode branch plus env plumbing in the product bin where the in-process route uses exported production functions; the bin's thin glue is covered by the keyless CLI smokes. Becomes free only if the web host is ever Loader-ized — declined in review, with the app-assembly ruling reaffirmed. + +**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. + +**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. + +**A client `data-dsh-busy` settled signal.** Deferred: the multi-condition settled polls proved sufficient at two scenarios and the host-side `whenIdle` barrier does the heavy lifting. Re-entry trigger: the first settled-poll flake, or a scenario needing a state the DOM does not expose. + +## Testing + +The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the existing smoke pair; `DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` re-records a scenario's fixture against the live model; `DSH_SNAPSHOT=refresh` rewrites both aria goldens keylessly. The `llm: false` seam is pinned by `packages/host/runtime/tests/host-runtime.spec.ts` (keyless boot, NO_ADAPTER at first stream, embedder fill through ctx); `paceMs` validation, pacing floor, abort-during-pace, and both `assertConsumed` failure shapes are pinned in `packages/support/llm-replay/tests/llm-replay.spec.ts`. + +## Deferred + +- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins bootHost's composed prompt/tool schemas (`TODO(web-header-pin)` — the harness `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. +- **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). +- **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. + +## Consequences + +The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compact-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the lane guards regressions only where it runs (locally, `test:web`) until the CI reversal is separately decided. diff --git a/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md deleted file mode 100644 index cc7504dae7..0000000000 --- a/.agents/notes/proposed/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ /dev/null @@ -1,92 +0,0 @@ -# Agent Note: Keyless browser e2e lane for the web GUI - -Status: proposed - -## Problem - -The web GUI ships as a real assembled chain — chromium page → nine client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → `bootHost`'s agent loop, tools, and JSONL persistence — and no test exercises that chain keylessly and deterministically. The [GUI testing system](../../implemented/process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and a tier-3 smoke pair, but the keyless smoke (`apps/web/tests/smoke-fixture.e2e.ts`) drives `FixtureApiClient` behind `?fixture` — no host, no wire, no agent loop — while the full-chain smoke (`smoke-real.e2e.ts`) needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface is the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. - -## Proposal - -Add a keyless, deterministic browser e2e lane under `apps/web/tests/`, driven by recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay`, asserting the rendered accessibility tree plus in-process world state. No new package; no product-code change except (open question 1) an LLM composition knob. - -### Harness: `apps/web/tests/harness.ts` - -A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic this lane needs — replay derivation, session parsing, log scrubbing, persistence — already lives in gated packages (`dsh-llm-replay`, `dsh-acp-snapshot`, `dsh-session-persistence-jsonl`); what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. - -`launchWebHarness()` boots the real web assembly in-process from the exported production functions — `startHost({ boot: { persistenceRoot: , workspaceContext: false, cwd: } })`, `installLlmReplay(host.ctx, { file, childFiles, providers })`, `mountWebPlugins(host.ctx)`, `createHostWebPluginRegistry`, `startWebServer({ port: 0, distIndex, apiHandler: host.handler, webPlugins })` — and returns `{ baseUrl, host, workspaceCwd, close }`. This is the web analog of the TUI suite mounting the production bundle in-process ([TUI snapshots](../../implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)): the real entry boundary (`dsh web` bin arg-parsing and dist resolution) stays held by the existing keyless CLI smokes in `smoke-real.e2e.ts`, and the web surface has no `cordis.yml` to bypass — assembly is written in the app per the [GUI layering decision](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md). Replay runs in providers-catalog mode with a `contextWindow` (the TUI suite's `PROVIDERS` shape), never catch-all: with no adapter registered, catch-all mode would make `compact-basic`'s `resolveModelContext` throw into its post-step catch every step, spamming warnings and silently disabling the pressure path instead of proving it inert. - -`seedSession(host, fixtureText)` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` for deterministic sidebar order (the precedent is `examples/acp-agent/tests/semantic-checkpoint.snapshot.ts`) — never raw file writes, so the seeder needs no knowledge of bucket hashing, filename encoding, or compression. Seeds are validated at seed time (parseable, `seq`-contiguous, ending in `turn/end`) so fixture drift fails loud at the earliest resolvable point rather than as silently dropped frames in the client; a seed not ending in `turn/end` would be mutated by resume's crash repair. - -### Determinism rules - -The barrier stack, in order, for a prompted turn: (1) host-side `await agent.whenIdle()` under a timeout — the idle flip happens after both the `turn/end` append and the persistence flush, so one await covers turn completion and durability; (2) browser settled poll — streaming node detached, composer restored, final text visible; (3) log harvest only after `host.dispose()`. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync), and file polling is banned (slow on NFS, superseded by `whenIdle`). For history-open scenarios the barrier is a poll for the last expected message's text, then a poll-until-equal aria capture. `networkidle` is banned outright — it never resolves while an SSE stream is open. - -No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof), optionally corroborated by an in-page MutationObserver latch armed before send — observers cannot miss a commit; polls can. `dsh-llm-replay` gains an opt-in `paceMs` config field (default absent = today's instant yield) as a realism knob so the browser observes genuinely incremental SSE; correctness never leans on the pace. - -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Harness `close()` asserts every replay script was fully consumed (all scripts bound, every cursor at end), converting silent underruns and shifted bindings into crisp diagnostics; this is a small additive stats handle on `installLlmReplay`. No vitest retry on the lane — a retried-green race is a flake deferred, and only the chromium launch itself may retry inside the harness, logged. One chromium per file, fresh browser context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text only. - -### Expected outputs - -One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region only (`ui.expected.md`) — uuid/cwd/duration tokens normalized, sidebar and other time-bearing chrome structurally excluded, captured poll-until-equal at the settled milestone. The accessibility tree is the mechanization of the client rule "assert what the user would see, never class names": it survives CSS-module hash churn, styling rewrites, and DOM restructuring, and a wholesale component rewrite refreshes it keylessly. Alongside the golden, three or four targeted role/text anchor assertions (heading level, `pre code` content, tool-row accessible name) keep green anchors under a semantics-preserving rewrite so a churned golden diff is reviewable against surviving anchors. World-state assertions ride `host.ctx` session events inline (which tools ran, `turn/end` completed, no error) instead of a second committed log golden: the persisted-log surface is already pinned by the ACP/headless/TUI suites through the same loop and persistence plugins, and re-pinning it here would double refresh cost for no new regression class. `playwright` gets pinned exactly in `apps/web/package.json` — the aria format is Playwright-owned, the one snapshot format in this repo we do not own, so version bumps must be deliberate bump-and-refresh commits. - -### Modes and fixtures - -`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless), as inline branches in the specs — the TUI suite's shape, not a suite-factory: at two scenarios the acp-snapshot factory machinery (scenario tables, pinning classes, Windows sidecars, packed-row stabilization) has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `normalizeSessionLog`, `parseSessionLog`, `installLlmReplay`). Each scenario script splits into drive steps (type, send, `whenIdle`-generic waits — run in all modes, never waiting on model-content selectors) and interaction/assertion steps (expand reasoning, aria capture — replay/refresh only), so record mode cannot hang on a live model answering with a different tool count. Record = drive + harvest the in-memory `session.header` + `session.events` (the TUI `rawSessionLog` shape — no file decompression, so `bootHost` needs no compression knob) + scrub via `scrubRequestHeaders` + a mandatory keyless refresh to regenerate `ui.expected.md`. Web fixtures scrub headers everywhere and pin nowhere, matching the TUI precedent; whether the web surface must instead own a header-class pin per the [pinned-header discipline](../../implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md) is open question 3. Seeds are recorded fixtures under the same inventory and refresh discipline as replay fixtures, never hand-authored one-offs, so `DSH_SNAPSHOT=refresh` heals every committed surface after intentional shape churn and only `assistant/chunk`-shape churn escalates to re-record. A TUI-style `afterAll` fixture guard holds the inventory closed (expected files present, every fixture scrub-fixed-point, no orphan directories). - -### Demo scenarios - -1. **`fresh-round-trip`** — new session, prompt, replay streams reasoning + markdown + a `bash` tool call that really executes (`echo` in the temp workspace) + final text. Asserts settled markdown semantics (heading, code block), the tool card row, composer restore, the aria golden, and inline world state (bash `tool/call` + completed `turn/end` in the session events). The keyless version of the with-key W5 flows. -2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it, opening it renders tool cards and collapsible reasoning purely from the log. This exercises the implicit cold-resume attach (`session.history` resumes via `agentFor`), cold summaries, history pagination views, and the client fold of historical events — the surface nothing else covers — with zero model calls, so no replay-binding constraints at all. A follow-up-prompt-after-resume scenario is deliberately deferred until the history/live stitch path changes or regresses. - -### Lane wiring and CI stance - -The lane rides `vitest.web.config.ts` (`pnpm run test:web`, serial), which stays gate-exempt exactly as its header comment records. Adding chromium to CI would reverse the "no browser infrastructure in CI" premise recorded in the [GUI testing system note](../../implemented/process/2026-07-20-gui-testing-system.md) and therefore requires its own Agent Note cross-linked from that note, staged as: non-required CI job first, promotion criteria measured (consecutive green runs, wall time, zero-retry flake budget, browser cache strategy on the enterprise runners, whether the runner images carry the chromium system libraries). Deferred out of this proposal; a `TODO(ci-browser)` marks the seam. Scenarios are `posixOnly` initially. Docs updated in the same implementation PR: the [testing policy](../../../../docs/testing.md) names `apps/web/tests/snapshots/` as the web surface's snapshot home with its divergent `DSH_SNAPSHOT=… pnpm run test:web` commands, the GUI testing note's tier map gains the lane (and drops its stale references to the deleted `missions/scripts/verify-*` files), `packages/client/AGENTS.md`'s check ladder mentions it, and the `dsh-acp-snapshot` README's "ACP-specific by design" sentence is corrected — this lane is the third consumer of its normalizers. - -### Open questions - -1. **LLM seam.** Two viable shapes. (a) `BootHostOptions.llm?: 'deepseek' | false` — an assembly toggle in the one module that owns assembly, matching the existing `workspaceContext: Config | false` shape and the reserved-knob sentence in `start.ts`; `llm: false` mounts no adapter, the harness fills the open seam on `host.ctx`, misuse fails loud at the first stream with `NO_ADAPTER`, and keyless boots stop needing any key. (b) Zero product change — a placeholder `DEEPSEEK_API_KEY` env var satisfies `llm-deepseek`'s load-time presence check (twice-precedented in-tree) and replay intercepts ahead of the mounted adapter. (a) is cleaner semantics and honest keylessness at the cost of a test-motivated product field; (b) is free but satisfies a fail-loud check with a lie and leaves a dead adapter mounted. Recommendation: (a), shaped minimal. -2. **Loader-izing `dsh web`.** Making the web host `cordis.yml`-driven like every example would give the ACP-style `cordis.snapshot.yml` replay overlay for free and align with "everything is a plugin", but it reverses the settled "assembly is written in the app" ruling and is a product-architecture decision on its own merits — its own proposal if wanted; this lane does not need it. -3. **Header-class pin.** Strict reading of the pinned-header discipline wants one web scenario pinning `bootHost`'s composed prompt + tool schemas (a header class no ACP scenario covers); the TUI precedent scrubs everywhere and pins nowhere. Cheap middle: pin sidecars on `fresh-round-trip` at record time. Recommendation: follow TUI now (scrub-only), revisit when the web assembly's header diverges further from the repl composition it mirrors. -4. **Golden breadth.** Full conversation-region aria golden (adopted above) versus targeted assertions only. The golden is the "assembled transcript" duty for user-visible changes; the cost is a keyless refresh on every component rewrite. Recommendation: keep the golden + anchors. -5. **Client settled signal.** A `data-dsh-busy` attribute derived from the object layer's pending-RPC/active-stream state would replace multi-condition settled polls with one selector. Presentation-plane observability, no session-log leak — but the current polls suffice for two scenarios. Recommendation: defer until a settled-poll flake actually appears. - -## Prior art - -Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot + AI SDK, lobe-chat, open-webui, OpenHands, Chainlit, continue, cline, langfuse, gradio/streamlit; Playwright HAR/route, MSW, Polly/nock, WireMock, aimock). The dominant proven architecture for apps that own their backend is an in-process fake/replay model behind the real backend seam with everything downstream real (LibreChat's `LIBRECHAT_TEST_RUN_HOOK` fake model; ai-chatbot's `MockLanguageModelV3` + `simulateReadableStream`; continue's scripted mock provider classes) — which is what `dsh-llm-replay` already is. Browser-level SSE interception cannot exercise incremental rendering (`route.fulfill` delivers the whole body at once; playwright#33564) and leaves the server SSE stack untested, so projects use it only for edge cases. Chunk pacing as a fixture parameter recurs everywhere (LibreChat 10ms default with slow profiles; ai-chatbot 500ms); real models in CI rot (open-webui's suite grew 120-second timeouts, was disabled, then deleted); sessions are seeded at the persistence layer with controlled timestamps (LibreChat inserts backdated Mongo documents; langfuse seeds its DB). No surveyed project replays a recorded agent-event log through the real backend for UI tests — the closest are provider-level recorded fixtures (aimock) and frontend-level socket history emission (OpenHands MSW) — so the session-log-as-fixture design goes one step beyond prior art along the axis this repo's model-visible ⟺ logged invariant makes natural. - -## Alternatives considered - -**Browser-network SSE interception (`page.route`).** Rejected: `route.fulfill` cannot stream, so incremental token rendering is unexercisable and the server-side SSE/backpressure/close path — where both confirmed P0s hid — goes untested. - -**Mock HTTP provider at `DEEPSEEK_BASE_URL`.** Rejected as the lane's mechanism (kept for the one existing workspace-probe smoke): fixtures become hand-authored OpenAI SSE byte scripts, a second fixture format that drifts from the session-log format the rest of the repo records and replays; the adapter's real HTTP path is with-key e2e's job. - -**Growing the `?fixture` client.** Rejected: tier separation — `FixtureApiClient` exists to test the client shell without a server; everything below the client API seam stays untested by construction. - -**A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected for now: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios the factory generalizes from one consumer while the genuinely shared logic already lives exported in `dsh-llm-replay`/`dsh-acp-snapshot`. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. - -**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers, against the tier discipline. Inline world-state assertions on `host.ctx` events keep the world-verification duty. - -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected for now: it needs a test-mode branch plus env plumbing in the product bin where the in-process route uses exported production functions with zero product change; the bin's thin glue is covered by the keyless CLI smokes. Becomes free if the web host is ever Loader-ized (open question 2). - -**Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. - -**Real-model browser tests as the keyless lane.** Rejected: nondeterministic by construction; the surveyed cautionary case (open-webui) grew unbounded timeouts and was deleted. The with-key W5 smoke stays as the live-model complement. - -## Acceptance criteria - -- `pnpm run test:web` keyless passes the two scenarios deterministically (no vitest retry), alongside the existing smoke pair, on a checkout with built client bundles and the frontend dist. -- Replay asserts: aria golden equality at the settled milestone, anchor role/text assertions, inline world-state event assertions, zero pageerrors, zero connection-loss/gap-repair console warnings, all replay scripts fully consumed at teardown. -- `DSH_SNAPSHOT=record` with a key re-records `fresh-round-trip` (drive steps only), rewrites its `session.jsonl` scrubbed, and a follow-up `DSH_SNAPSHOT=refresh` regenerates `ui.expected.md` keylessly; `refresh` alone heals goldens after intentional non-chunk shape churn. -- The seeded scenario renders history through the real cold-resume path with zero model calls and leaves the seed fixture byte-identical (closedness validated at seed time). -- Fixture guard holds the snapshot inventory closed; failure produces a bundle under `.artifacts/` (screenshot, console, pageerrors, persistence copy, actual-vs-expected aria). -- Docs land in the same PR: testing.md web-lane entry, GUI testing note tier map + stale verify-script cleanup, client AGENTS.md ladder, acp-snapshot README correction, this note moved to `implemented/` rewritten in present tense. - -## Risks - -- **The aria format is Playwright-owned** — the one committed snapshot format the repo does not control; a version bump can churn every golden. Mitigated by an exact version pin in `apps/web` and a documented bump-and-refresh procedure; residual risk accepted. -- **Replay's first-call-order binding** stays fragile under concurrent browser-driven sessions; the lane constrains scenarios to one prompting session each (the seeded scenario prompts none), and the teardown consumption assertion turns violations into diagnostics rather than surreal transcripts. -- **`compact-basic` shares the session's replay cursor** — a pressure-triggered summarize would consume a script entry; inert for small fixtures under the 128k catalog window, and the consumption assertion catches it if a fixture ever grows past the threshold. -- **CI remains browserless for now**, so the lane guards regressions only where it is run (locally and in any future non-required job) until the CI reversal is separately decided; the runner images' chromium-library situation is unverified. -- **Record-mode nondeterminism** is contained but not eliminated by the drive/assert split: a live model may still produce a transcript whose replay violates a scenario's assertions, requiring prompt tuning at record time (bounded by terse prompts and a chunk-count warning in record mode). -- **jsdom-lane overlap**: component-level rendering is already covered per-plugin; this lane must stay at assembled-transcript altitude (whole-region golden + anchors) or it starts re-testing tier 2 and paying double maintenance. diff --git a/apps/web/tests/harness.ts b/apps/web/tests/harness.ts index 6f885da0c8..71c3ac9344 100644 --- a/apps/web/tests/harness.ts +++ b/apps/web/tests/harness.ts @@ -243,11 +243,11 @@ export function rawSessionLog(session: Session): string { /** * Record-mode fixture write-back: harvest the live session, scrub request - * headers to {{system}}/{{tools}} (the web lane pins no header class — a - * deliberate deviation logged in the Agent Note's deferred work), tokenize - * the run-local session id and cwd ({{sessionId}}/{{cwd}}, the committed ACP - * fixture convention — re-records then diff only on real content), and write - * the committed fixture. + * headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no + * header class — a deliberate deviation logged in the Agent Note's deferred + * work), tokenize the run-local session id and cwd ({{sessionId}}/{{cwd}}, + * the committed ACP fixture convention — re-records then diff only on real + * content), and write the committed fixture. * @param harness - the record-mode harness. * @param sessionId - the driven session. * @param fixturePath - the committed session.jsonl / seed.jsonl target. diff --git a/docs/testing.md b/docs/testing.md index 85798cee3e..5841cd5f29 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,6 +8,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Web browser snapshot** (inside `pnpm run test:web`, gate-exempt like the rest of that lane): the web GUI's keyless assembled-transcript tier — a real chromium over the real in-process web assembly (`llm: false` + `dsh-llm-replay`), scenario fixtures and normalized conversation aria goldens under `apps/web/tests/snapshots/`. Its record/refresh commands diverge from `test:snapshot` (`DSH_SNAPSHOT=record pnpm run test:web` re-records against the live model; `DSH_SNAPSHOT=refresh` rewrites goldens keylessly); the [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md) owns the design, and CI browser provisioning is deferred there. ## The with-key policy: inference is cheap here @@ -41,4 +42,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/` through the web e2e lane's harness. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 5bde15dc2c..3c96ac1e85 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -65,7 +65,7 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy Run the narrowest rung that covers what you touched; escalate only when the change surface demands it. 1. **Every GUI code change** — `pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck. -2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`). +2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key). 3. **Before a PR** — `pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit. If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 05a4974c19..1243cd5026 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -42,7 +42,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). +Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). ## Model Experience diff --git a/vitest.web.config.ts b/vitest.web.config.ts index de220c9f12..bcca93e5dd 100644 --- a/vitest.web.config.ts +++ b/vitest.web.config.ts @@ -1,10 +1,13 @@ import tsconfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vitest/config' -// Web smoke lane (GUI, gate-exempt — not part of the CI sequence yet): built -// page + real chromium, so it lives outside the unit/e2e includes. The -// real-host test self-skips without DEEPSEEK_API_KEY; the fixture test is -// keyless and deterministic. +// Web browser lane (GUI, gate-exempt — not part of the CI sequence yet): +// built page + real chromium, so it lives outside the unit/e2e includes. The +// real-host smoke self-skips without DEEPSEEK_API_KEY; the fixture smoke and +// the replayed e2e scenarios are keyless and deterministic. +// TODO(ci-browser): running this lane in CI requires chromium provisioning +// and reverses the no-browser-in-CI ruling — staged criteria in +// .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md. try { // Node >= 21.7 native; throws when the file does not exist. process.loadEnvFile(new URL('.env', import.meta.url).pathname) From ee5132c1e190759c10daa6c0dd8fa47e08b27c28 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 24 Jul 2026 20:01:38 +0800 Subject: [PATCH 20/70] 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 21/70] 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 d08050ab69256cdc5f4183dd0b56dbad475fac59 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:30:33 +0800 Subject: [PATCH 22/70] =?UTF-8?q?chore(web-e2e):=20gate=20fixes=20?= =?UTF-8?q?=E2=80=94=20catalog,=20budgets,=20knip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regenerate config-catalog for the llm-replay paceMs row; condense the testing.md web-lane entry to pointer form and raise its ceiling 1020->1060 (the two-sentence tier entry for a genuinely new surface does not fit the old ceiling after relocation-first trims); internalize two harness helpers knip flagged (rawSessionLog/normalizeAria are module-internal). --- apps/web/tests/harness.ts | 9 ++----- docs/config-catalog.md | 2 +- docs/testing.md | 4 +-- .../llm-replay/tests/llm-replay.spec.ts | 27 ++++++++++++++++++- scripts/doc-budgets.manifest.json | 2 +- 5 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/harness.ts b/apps/web/tests/harness.ts index 71c3ac9344..b2941c62e0 100644 --- a/apps/web/tests/harness.ts +++ b/apps/web/tests/harness.ts @@ -230,10 +230,8 @@ export async function launchWebHarness(options: LaunchOptions = {}): Promise JSON.stringify(event)), @@ -333,11 +331,8 @@ export async function seedSession(harness: WebHarness, fixtureText: string, id: /** * Normalize an aria snapshot: uuid, cwd, workspace-basename, and duration * volatility collapse to stable tokens. - * @param snapshot - raw ariaSnapshot text. - * @param workspaceCwd - the harness workspace (basename doubles as the header breadcrumb). - * @returns tokenized snapshot text. */ -export function normalizeAria(snapshot: string, workspaceCwd: string): string { +function normalizeAria(snapshot: string, workspaceCwd: string): string { // The header breadcrumb renders the workspace's basename, not the full // path, so both spellings must collapse to the token. const base = workspaceCwd.split('/').pop()! diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c06fef3d4d..1b546be67d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -643,7 +643,7 @@ export interface ReplayModelConfig { } ``` -Source: [`packages/support/llm-replay/src/index.ts:453`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:454`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/testing.md b/docs/testing.md index 5841cd5f29..78ce3c2856 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -8,7 +8,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). - **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless expected outputs cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the headless suite independently pins `stream-json` through its real one-shot subprocess. TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state expected outputs; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot Agent Note](../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and expected-output diff. System-prompt/tool-schema content is pinned by one ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header Agent Note](../.agents/notes/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). -- **Web browser snapshot** (inside `pnpm run test:web`, gate-exempt like the rest of that lane): the web GUI's keyless assembled-transcript tier — a real chromium over the real in-process web assembly (`llm: false` + `dsh-llm-replay`), scenario fixtures and normalized conversation aria goldens under `apps/web/tests/snapshots/`. Its record/refresh commands diverge from `test:snapshot` (`DSH_SNAPSHOT=record pnpm run test:web` re-records against the live model; `DSH_SNAPSHOT=refresh` rewrites goldens keylessly); the [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md) owns the design, and CI browser provisioning is deferred there. +- **Web browser snapshot** (gate-exempt `pnpm run test:web`): real chromium over the in-process web assembly replays recorded fixtures against conversation aria goldens (`apps/web/tests/snapshots/`); `DSH_SNAPSHOT=record`/`refresh` semantics and the deferred CI browser decision live in the [web e2e lane Agent Note](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). ## The with-key policy: inference is cheap here @@ -42,4 +42,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/` through the web e2e lane's harness. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model- or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index b6b03aacbf..d42626bedd 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -476,6 +476,31 @@ describe('installLlmReplay (through the real LlmService)', () => { expect(() => { handle.assertConsumed() }).not.toThrow() }) + it('paces a throw-entry prefix too (the recorded partial streams at the same cadence)', async () => { + writeFileSync(file, sessionJsonl([]), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] + writeFileSync(overrideFile, JSON.stringify([ + { kind: 'throw', chunks: partial, message: 'boom', code: 'STREAM_CLOSED' }, + ]), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file, overrideFile, paceMs: 10 }) + const started = performance.now() + await expect(drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).rejects.toThrow('boom') + expect(performance.now() - started).toBeGreaterThanOrEqual(5) + }) + + it('assertConsumed names an underrunning identified session by its id', async () => { + writeLog(TEXT_CHUNKS, TEXT_CHUNKS) + const ctx = new Context() + await ctx.plugin(LlmService) + const handle = installLlmReplay(ctx, { file }) + const sessionId = 'live-underrun' as NonNullable + await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId })) + expect(() => { handle.assertConsumed() }).toThrow(/session live-underrun consumed 1\/2/) + }) + it('assertConsumed reports recorded scripts no live session ever bound', async () => { writeLog(TEXT_CHUNKS) const childFile = join(dir, 'session.1.jsonl') @@ -690,7 +715,7 @@ describe('apply (the plugin entry)', () => { writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) - apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }] }) + apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 }) expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }]) expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 7e4d154174..fe61bfa69d 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1020, + "docs/testing.md": 1060, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, "packages/README.md": 760 From ba6fc1cfa3a17d0bfe92e214d8ddfd953ce94355 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 20:50:53 +0800 Subject: [PATCH 23/70] 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 8f97f95d7bb03889bee91d14ad5b03a1fca7c6f9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 21:06:54 +0800 Subject: [PATCH 24/70] docs(i18n): bilingual pair for the web e2e lane Agent Note Chinese counterpart translated per the terminology table and the 2026-07-18 TUI note's register; switcher lines added on both sides; pair recorded. doc-sync 24/24. --- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 6 ++ .../2026-07-24-web-gui-browser-e2e-lane.md | 2 + .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 90 +++++++++++++++++++ 3 files changed, 98 insertions(+) create mode 100644 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml new file mode 100644 index 0000000000..1f55dfce3e --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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-web-gui-browser-e2e-lane.md: 3cabceb9667d3d1c153518d58b8d4c02b0578d20 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 132caa453662f48619aa542c68b59f59b64acd0f diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index fddd1e9a0c..3cabceb966 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -2,6 +2,8 @@ Status: implemented +English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md) + ## Problem The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → `bootHost`'s agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md new file mode 100644 index 0000000000..132caa4536 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -0,0 +1,90 @@ +# Agent Note: Web GUI 的无密钥浏览器 e2e 车道 + +Status: implemented + +[English](2026-07-24-web-gui-browser-e2e-lane.md) | 中文 + +## 问题 + +Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → `bootHost` 的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个文本记录(transcript)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。 + +## 决策 + +`pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组装回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量只有 `BootHostOptions.llm` seam 和 `dsh-llm-replay` 的两处增量接口。 + +### Harness:`apps/web/tests/harness.ts` + +一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 + +`launchWebHarness()` 用导出的生产函数在进程内启动真实 web 组装——`startHost({ boot: { …, llm: false } })`、`installLlmReplay(host.ctx, { file, providers, paceMs })`、`mountWebPlugins(host.ctx, roster, anchor)`、`createHostWebPluginRegistry`、`startWebServer({ port: 0, … })`。这是 TUI 套件进程内挂载生产 bundle 的 web 对应物([TUI 快照](2026-07-18-tui-terminal-state-snapshots.md)):真实入口边界(`dsh web` bin 的参数解析、dist 解析)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守,且 web 表面没有可绕过的 `cordis.yml`——按[GUI 分层决策](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md),组装写在应用里;本车道的设计评审明确重申了这一裁定(Loader 化 `dsh web` 被否决;那需要自己的提案)。与 `dsh web` shell 的两处刻意组装差异已注明在 harness 头部:`workspaceContext: false`(录制的 fixture 不得嵌入本仓库的 AGENTS.md),以及 `sessionTitleLlm` 保持 bootHost 的禁用默认值(其发后不管的标题调用会与循环自身的调用不确定地共享会话的回放游标)。 + +`llm: false` seam 是无密钥启动问题经评审后的定论:`BootHostOptions` 上的 `'deepseek' | false`,与 `workspaceContext: Config | false` 形态一致,且 `RunningHost.ctx` 的 JSDoc 把「填充刻意开放的能力 seam」列为其第三种认可用法。回放必须以提供方目录(providers-catalog)模式运行并发布 `contextWindow`(TUI 的 `PROVIDERS` 形态),绝不用 catch-all:没有注册适配器时,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置。 + +`seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。 + +### 确定性规则 + +提示一轮对话的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见);(3)任何日志采收都在 `host.dispose()` 之后。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。 + +不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 + +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Harness 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;选择器只锚定 role、`data-*` 属性和可见文本。 + +### 预期输出 + +每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在 `host.ctx` 会话事件上(哪些工具运行了、`turn/end` 完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 + +类型检查平面切分是结构性的:`apps/web/tests/{harness,support,replay-round-trip.e2e,seeded-history.e2e}.ts` 是 host 平面程序(它们启动 host 主干),因此被排除出注册在 client 侧的 `apps/web` 工程,逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 + +### 模式与 fixture + +`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 + +### 场景 + +1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(bash `tool/call`、完成的 `turn/end`、>10 个分片事件)。 +2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 + +### CI 立场 + +车道随 `pnpm run test:web` 交付、豁免门禁,与该配置头部注释所记一致。往 CI 加 chromium 会推翻 [GUI 测试笔记](../process/2026-07-20-gui-testing-system.md)中「CI 无浏览器基础设施」的前提,因此需要自己的 Agent Note 并从那里交叉链接,分阶段推进:先作为非必需任务,再以量化标准晋升(连续绿色运行次数、耗时、零重试的抖动预算、runner 浏览器缓存策略)。`TODO(ci-browser)` 标记该接缝。场景目前面向 POSIX(车道不在 Windows 矩阵中)。 + +## 业界先例 + +调研了 AI 聊天/agent web UI 与 mock 层(LibreChat、vercel/ai-chatbot + AI SDK、lobe-chat、open-webui、OpenHands、Chainlit、continue、cline、langfuse、gradio/streamlit;Playwright HAR/route、MSW、Polly/nock、WireMock、aimock)。自有后端的应用的主流成熟架构是:真实后端 seam 后放一个进程内伪造/回放模型,下游全部真实(LibreChat 的 `LIBRECHAT_TEST_RUN_HOOK` 伪模型;ai-chatbot 的 `MockLanguageModelV3` + `simulateReadableStream`;continue 的脚本化 mock 提供方类)——这正是 `dsh-llm-replay` 已然所是。浏览器层 SSE 拦截无法检验增量渲染(`route.fulfill` 一次性交付整个响应体;playwright#33564),且服务端 SSE 栈完全失测,因此各项目只把它用于边缘用例。分片节奏作为 fixture 参数反复出现(LibreChat 默认 10ms 附慢速档;ai-chatbot 500ms);CI 里的真实模型会腐烂(open-webui 的套件长出 120 秒超时,先被禁用后被删除);会话在持久化层以受控时间戳播种(LibreChat 直插回拨时间的 Mongo 文档;langfuse 播种其数据库)。没有任何被调研项目为 UI 测试把录制的 agent 事件日志经真实后端回放——最接近的是提供方层录制 fixture(aimock)与前端层 socket 历史发射(OpenHands MSW)——因此会话日志即 fixture 的设计沿着本仓库「模型可见 ⟺ 已记录」不变式所指的方向比业界先例多走了一步。 + +## 曾考虑的替代方案 + +**浏览器网络层 SSE 拦截(`page.route`)。** 已否决:`route.fulfill` 无法流式输出,增量 token 渲染无从检验,且服务端 SSE/背压/关闭路径——两起已实证 P0 的藏身处——完全失测。 + +**`DEEPSEEK_BASE_URL` 处的 mock HTTP 提供方。** 作为本车道机制已否决(仅保留给既有的工作区探针冒烟):fixture 会变成手写的 OpenAI SSE 字节脚本,一种与仓库其余部分录制回放的会话日志格式渐行渐远的第二 fixture 格式;适配器的真实 HTTP 路径归带密钥 e2e 管。 + +**扩展 `?fixture` 客户端。** 已否决:分层纪律——`FixtureApiClient` 的存在意义就是脱离服务器测试客户端 shell;client API seam 以下按构造即失测。 + +**用占位 `DEEPSEEK_API_KEY` + 回放拦截替代 `llm: false` seam。** 尽管零产品改动且树内有两处先例仍被否决:它用谎言满足 `llm-deepseek` 的快速失败密钥检查,还留下一个挂载却被拦截的死适配器;seam 方案与既有选项形态一致,并在最早可解析点快速失败。 + +**`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且两个场景就上工厂是从单一消费方过度泛化,真正共享的逻辑已从受门禁的包中导出。重启条件:出现第二个 web 形态消费方,或 ≥6 个场景的内联分支被证实各自漂移;届时包边界将画在无浏览器一侧。 + +**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在 `host.ctx` 事件上的世界状态断言保住了验证世界的义务。 + +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在产品 bin 里加测试模式分支和环境变量管道,而进程内路线用的是零产品改动的导出生产函数;bin 的薄胶水已由无密钥 CLI 冒烟覆盖。只有 web host 某天 Loader 化它才免费——评审中已否决,并重申了应用内组装的裁定。 + +**为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 + +**以真实模型浏览器测试充当无密钥车道。** 已否决:按构造即不确定;被调研的前车之鉴(open-webui)长出无界超时后被删除。带密钥的 W5 冒烟仍是真实模型侧的补充。 + +**客户端 `data-dsh-busy` 安定信号。** 暂缓:两个场景下多条件安定轮询已经够用,host 侧 `whenIdle` 屏障承担了重活。重启条件:第一次安定轮询抖动,或某场景需要等待 DOM 不暴露的状态。 + +## Testing + +车道自身:`pnpm run test:web` 与既有冒烟对一起无密钥运行两个场景;`DSH_SNAPSHOT=record pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/` 对真实模型重录某场景的 fixture;`DSH_SNAPSHOT=refresh` 无密钥重写两份 aria 预期输出。`llm: false` seam 由 `packages/host/runtime/tests/host-runtime.spec.ts` 钉住(无密钥启动、首次流式调用 NO_ADAPTER、嵌入方经 ctx 填充);`paceMs` 校验、节奏下限、节奏中中止、`assertConsumed` 的两种失败形态钉在 `packages/support/llm-replay/tests/llm-replay.spec.ts`。 + +## 暂缓 + +- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 bootHost 组装的提示词/工具 schema(`TODO(web-header-pin)`——harness 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 +- **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 +- **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 + +## 后果 + +Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compact-basic` 与会话共享回放游标,仅在发布的 128k 目录窗口下保持闲置;在 CI 反转被单独决策之前,车道只在其运行之处(本地,`test:web`)把守回归。 From 224e00b2bdf53d3d0083f43681452f532317d85a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Fri, 24 Jul 2026 21:27:07 +0800 Subject: [PATCH 25/70] 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 6d3c25f494a3e9bd48ae02a7e5200773e4ec5261 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:18:19 +0800 Subject: [PATCH 26/70] refactor(web-e2e): rename harness -> scaffold; add interaction coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared test module was named harness.ts inside a repo whose product IS a harness — hopelessly ambiguous. Renamed to scaffold.ts with launchWebScaffold/WebScaffold; tsconfig plane-split entries, the seam JSDoc/README mentions, and both Agent Note languages updated. Both scenarios gain a Playwright interaction step over the settled transcript (after the golden capture, so committed aria surfaces stay untouched): replay-round-trip clicks the reasoning fold open/closed over wire-delivered state; seeded-history expands a read tool row rebuilt from the cold log and asserts the recorded result text appears (read rows are expand-in-place — rowExpands routes the click to the inline fold, not the details column). test:web 30 passed | 1 skipped. --- .../2026-07-24-web-gui-browser-e2e-lane.md | 10 ++--- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 10 ++--- apps/web/tests/replay-round-trip.e2e.ts | 36 ++++++++++----- apps/web/tests/{harness.ts => scaffold.ts} | 40 ++++++++--------- apps/web/tests/seeded-history.e2e.ts | 45 +++++++++++++------ apps/web/tsconfig.json | 4 +- packages/host/runtime/README.md | 2 +- packages/host/runtime/src/boot.ts | 2 +- tsconfig.host.json | 2 +- 9 files changed, 91 insertions(+), 60 deletions(-) rename apps/web/tests/{harness.ts => scaffold.ts} (93%) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 3cabceb966..9bab4d1eb9 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -12,11 +12,11 @@ The web GUI ships as a real assembled chain — chromium page → client plugin `pnpm run test:web` carries a keyless, deterministic browser e2e lane under `apps/web/tests/`: recorded session-log fixtures replayed through `@deepseek-ai/dsh-llm-replay` against the real in-process web assembly, asserting a normalized conversation aria golden plus in-process world state. No new package; the product deltas are the `BootHostOptions.llm` seam and two additive `dsh-llm-replay` surfaces. -### Harness: `apps/web/tests/harness.ts` +### Scaffold: `apps/web/tests/scaffold.ts` A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebHarness()` boots the real web assembly in-process from the exported production functions — `startHost({ boot: { …, llm: false } })`, `installLlmReplay(host.ctx, { file, providers, paceMs })`, `mountWebPlugins(host.ctx, roster, anchor)`, `createHostWebPluginRegistry`, `startWebServer({ port: 0, … })`. This is the web analog of the TUI suite mounting the production bundle in-process ([TUI snapshots](2026-07-18-tui-terminal-state-snapshots.md)): the real entry boundary (`dsh web` bin arg-parsing, dist resolution) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`, and the web surface has no `cordis.yml` to bypass — assembly is written in the app per the [GUI layering decision](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md), a ruling this lane's design review explicitly reaffirmed (Loader-izing `dsh web` was declined; it would be its own proposal). Two deliberate assembly divergences from the `dsh web` shell, noted in the harness header: `workspaceContext: false` (recorded fixtures must not embed this repo's AGENTS.md) and `sessionTitleLlm` left at bootHost's disabled default (its fire-and-forget title call would share the session's replay cursor nondeterministically). +`launchWebScaffold()` boots the real web assembly in-process from the exported production functions — `startHost({ boot: { …, llm: false } })`, `installLlmReplay(host.ctx, { file, providers, paceMs })`, `mountWebPlugins(host.ctx, roster, anchor)`, `createHostWebPluginRegistry`, `startWebServer({ port: 0, … })`. This is the web analog of the TUI suite mounting the production bundle in-process ([TUI snapshots](2026-07-18-tui-terminal-state-snapshots.md)): the real entry boundary (`dsh web` bin arg-parsing, dist resolution) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`, and the web surface has no `cordis.yml` to bypass — assembly is written in the app per the [GUI layering decision](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md), a ruling this lane's design review explicitly reaffirmed (Loader-izing `dsh web` was declined; it would be its own proposal). Two deliberate assembly divergences from the `dsh web` shell, noted in the scaffold header: `workspaceContext: false` (recorded fixtures must not embed this repo's AGENTS.md) and `sessionTitleLlm` left at bootHost's disabled default (its fire-and-forget title call would share the session's replay cursor nondeterministically). The `llm: false` seam is the reviewed resolution of the keyless-boot question: `'deepseek' | false` on `BootHostOptions`, matching the `workspaceContext: Config | false` shape, with `RunningHost.ctx` JSDoc naming "filling a deliberately-open capability seam" as its third sanctioned use. Replay runs in providers-catalog mode with a published `contextWindow` (the TUI `PROVIDERS` shape), never catch-all: with no adapter registered, catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert. @@ -28,13 +28,13 @@ The barrier stack for a prompted turn, in order: (1) host-side `await agent.when No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Harness `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text. ### Expected outputs One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride `host.ctx` session events inline (which tools ran, `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. -The typecheck plane split is structural: `apps/web/tests/{harness,support,replay-round-trip.e2e,seeded-history.e2e}.ts` are host-plane programs (they boot the host spine), so they are excluded from the client-registered `apps/web` project and included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. +The typecheck plane split is structural: `apps/web/tests/{scaffold,support,replay-round-trip.e2e,seeded-history.e2e}.ts` are host-plane programs (they boot the host spine), so they are excluded from the client-registered `apps/web` project and included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. ### Modes and fixtures @@ -81,7 +81,7 @@ The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the ## Deferred -- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins bootHost's composed prompt/tool schemas (`TODO(web-header-pin)` — the harness `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. +- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins bootHost's composed prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. - **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 132caa4536..c61aee7ae5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -12,11 +12,11 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu `pnpm run test:web` 携带 `apps/web/tests/` 下的无密钥、确定性浏览器 e2e 车道:录制的会话日志 fixture 经 `@deepseek-ai/dsh-llm-replay` 对真实进程内 web 组装回放,断言规范化后的会话区 aria 预期输出加进程内世界状态。不新增包(package);产品侧增量只有 `BootHostOptions.llm` seam 和 `dsh-llm-replay` 的两处增量接口。 -### Harness:`apps/web/tests/harness.ts` +### Scaffold:`apps/web/tests/scaffold.ts` 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebHarness()` 用导出的生产函数在进程内启动真实 web 组装——`startHost({ boot: { …, llm: false } })`、`installLlmReplay(host.ctx, { file, providers, paceMs })`、`mountWebPlugins(host.ctx, roster, anchor)`、`createHostWebPluginRegistry`、`startWebServer({ port: 0, … })`。这是 TUI 套件进程内挂载生产 bundle 的 web 对应物([TUI 快照](2026-07-18-tui-terminal-state-snapshots.md)):真实入口边界(`dsh web` bin 的参数解析、dist 解析)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守,且 web 表面没有可绕过的 `cordis.yml`——按[GUI 分层决策](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md),组装写在应用里;本车道的设计评审明确重申了这一裁定(Loader 化 `dsh web` 被否决;那需要自己的提案)。与 `dsh web` shell 的两处刻意组装差异已注明在 harness 头部:`workspaceContext: false`(录制的 fixture 不得嵌入本仓库的 AGENTS.md),以及 `sessionTitleLlm` 保持 bootHost 的禁用默认值(其发后不管的标题调用会与循环自身的调用不确定地共享会话的回放游标)。 +`launchWebScaffold()` 用导出的生产函数在进程内启动真实 web 组装——`startHost({ boot: { …, llm: false } })`、`installLlmReplay(host.ctx, { file, providers, paceMs })`、`mountWebPlugins(host.ctx, roster, anchor)`、`createHostWebPluginRegistry`、`startWebServer({ port: 0, … })`。这是 TUI 套件进程内挂载生产 bundle 的 web 对应物([TUI 快照](2026-07-18-tui-terminal-state-snapshots.md)):真实入口边界(`dsh web` bin 的参数解析、dist 解析)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守,且 web 表面没有可绕过的 `cordis.yml`——按[GUI 分层决策](../architecture/2026-07-19-gui-layering-and-rpc-protocol.md),组装写在应用里;本车道的设计评审明确重申了这一裁定(Loader 化 `dsh web` 被否决;那需要自己的提案)。与 `dsh web` shell 的两处刻意组装差异已注明在 scaffold 头部:`workspaceContext: false`(录制的 fixture 不得嵌入本仓库的 AGENTS.md),以及 `sessionTitleLlm` 保持 bootHost 的禁用默认值(其发后不管的标题调用会与循环自身的调用不确定地共享会话的回放游标)。 `llm: false` seam 是无密钥启动问题经评审后的定论:`BootHostOptions` 上的 `'deepseek' | false`,与 `workspaceContext: Config | false` 形态一致,且 `RunningHost.ctx` 的 JSDoc 把「填充刻意开放的能力 seam」列为其第三种认可用法。回放必须以提供方目录(providers-catalog)模式运行并发布 `contextWindow`(TUI 的 `PROVIDERS` 形态),绝不用 catch-all:没有注册适配器时,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置。 @@ -28,13 +28,13 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Harness 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;选择器只锚定 role、`data-*` 属性和可见文本。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;选择器只锚定 role、`data-*` 属性和可见文本。 ### 预期输出 每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在 `host.ctx` 会话事件上(哪些工具运行了、`turn/end` 完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 -类型检查平面切分是结构性的:`apps/web/tests/{harness,support,replay-round-trip.e2e,seeded-history.e2e}.ts` 是 host 平面程序(它们启动 host 主干),因此被排除出注册在 client 侧的 `apps/web` 工程,逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 +类型检查平面切分是结构性的:`apps/web/tests/{scaffold,support,replay-round-trip.e2e,seeded-history.e2e}.ts` 是 host 平面程序(它们启动 host 主干),因此被排除出注册在 client 侧的 `apps/web` 工程,逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 ### 模式与 fixture @@ -81,7 +81,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 暂缓 -- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 bootHost 组装的提示词/工具 schema(`TODO(web-header-pin)`——harness 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 +- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 bootHost 组装的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 - **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index faf1a1f6a8..c2476ed3ea 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -16,8 +16,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebHarness, recordFixture, watchConsole, webSnapshotMode, type WebHarness, -} from './harness.ts' + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/fresh-round-trip', import.meta.url)) @@ -31,27 +31,27 @@ const MODE = webSnapshotMode() const PROMPT = 'Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop.' describe('web e2e: fresh round trip through the real assembly', () => { - let harness: WebHarness + let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - harness = await launchWebHarness({ + scaffold = await launchWebScaffold({ ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }), }) - harness.host.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) + scaffold.host.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) - await page.goto(harness.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) afterAll(async () => { await browser?.close() - await harness?.close() + await scaffold?.close() }) it('drives the recorded prompt to a settled turn (all modes)', async () => { @@ -63,12 +63,12 @@ describe('web e2e: fresh round trip through the real assembly', () => { const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) // Arm the host-side settled barrier BEFORE the send click. - const settled = harness.whenTurnSettled() + const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled if (MODE === 'record') { - await recordFixture(harness, sessionId, FIXTURE) + await recordFixture(scaffold, sessionId, FIXTURE) } }, 200_000) @@ -96,14 +96,28 @@ describe('web e2e: fresh round trip through the real assembly', () => { // while the whole-region golden churns. await expect(page.getByRole('textbox').first().isVisible()).resolves.toBe(true) expect(await page.getByText('WEB_E2E_OK', { exact: false }).count()).toBeGreaterThanOrEqual(1) - const snapshot = await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('expands and collapses the reasoning fold from its click target', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-round-trip-think')) + // Interaction over the REAL wire-delivered transcript (the fixture-client + // tier pins the same gesture against FixtureApiClient; this one runs on + // mux-frame-fed state). Runs after the golden capture so the committed + // aria surface stays the untouched settled state. + const think = page.getByRole('button', { name: /^Think/ }).first() + expect(await think.getAttribute('aria-expanded')).toBe('false') + await think.click() + await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') + await think.click() + await expect.poll(() => think.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + }) + it.skipIf(MODE === 'record')('stayed clean: no pageerrors, no reconnect self-healing, no server errors', async () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - expect(harness.serverErrors).toEqual([]) + expect(scaffold.serverErrors).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tests/harness.ts b/apps/web/tests/scaffold.ts similarity index 93% rename from apps/web/tests/harness.ts rename to apps/web/tests/scaffold.ts index b2941c62e0..c7fb3b04fe 100644 --- a/apps/web/tests/harness.ts +++ b/apps/web/tests/scaffold.ts @@ -1,4 +1,4 @@ -// Shared harness for the keyless browser e2e lane (Agent Note: +// Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). // Boots the REAL web assembly in-process from the exported production // functions — startHost (bootHost spine) + mountWebPlugins + registry + @@ -78,9 +78,9 @@ function loadRootEnv(): void { } } -/** A booted web harness: real assembly, mode-selected model backend, temp world. */ -export interface WebHarness { - /** The active snapshot mode this harness booted under. */ +/** A booted web scaffold: real assembly, mode-selected model backend, temp world. */ +export interface WebScaffold { + /** The active snapshot mode this scaffold booted under. */ mode: WebSnapshotMode /** Browser-facing origin (http://127.0.0.1:). */ baseUrl: string @@ -98,7 +98,7 @@ export interface WebHarness { close(): Promise } -/** Options for {@link launchWebHarness}. */ +/** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { /** * Replay fixture (session.jsonl) served by dsh-llm-replay in replay/refresh @@ -114,9 +114,9 @@ export interface LaunchOptions { /** * Boot the real web assembly under the current snapshot mode. * @param options - replay fixture selection and pacing. - * @returns the running harness. + * @returns the running scaffold. */ -export async function launchWebHarness(options: LaunchOptions = {}): Promise { +export async function launchWebScaffold(options: LaunchOptions = {}): Promise { requireDist() const mode = webSnapshotMode() if (mode === 'record') { @@ -221,7 +221,7 @@ export async function launchWebHarness(options: LaunchOptions = {}): Promise failures.push(e)) await rm(workspaceCwd, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) await rm(persistenceRoot, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) - if (failures.length > 0) throw new AggregateError(failures, 'web harness teardown failed') + if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, } } @@ -246,16 +246,16 @@ function rawSessionLog(session: Session): string { * work), tokenize the run-local session id and cwd ({{sessionId}}/{{cwd}}, * the committed ACP fixture convention — re-records then diff only on real * content), and write the committed fixture. - * @param harness - the record-mode harness. + * @param scaffold - the record-mode scaffold. * @param sessionId - the driven session. * @param fixturePath - the committed session.jsonl / seed.jsonl target. */ -export async function recordFixture(harness: WebHarness, sessionId: SessionId, fixturePath: string): Promise { - const agent = harness.host.ctx.agents.get(sessionId) +export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, fixturePath: string): Promise { + const agent = scaffold.host.ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`record harvest: no live agent for ${sessionId}`) const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) .split(sessionId).join('{{sessionId}}') - .split(harness.workspaceCwd).join('{{cwd}}') + .split(scaffold.workspaceCwd).join('{{cwd}}') await writeFile(fixturePath, tokenized) } @@ -274,27 +274,27 @@ export function fixtureUserPrompts(fixtureText: string): string[] { } /** - * Seed a recorded session fixture into the harness's persistence root through + * Seed a recorded session fixture into the scaffold's persistence root through * the REAL backend API (throwaway Context + SessionStore + JSONL plugin — the * semantic-checkpoint precedent), never raw file writes: no knowledge of * bucket hashing, filename encoding, or compression, and malformed shapes * fail loud at seed time. The fixture's recorded cwd is rewritten to the - * harness workspace so header/path identity and event payload paths agree. - * @param harness - the target harness. + * scaffold workspace so header/path identity and event payload paths agree. + * @param scaffold - the target scaffold. * @param fixtureText - raw recorded session.jsonl contents. * @param id - the seeded session id (stable for deterministic goldens). * @returns the seeded id. */ -export async function seedSession(harness: WebHarness, fixtureText: string, id: string): Promise { +export async function seedSession(scaffold: WebScaffold, fixtureText: string, id: string): Promise { // Committed fixtures tokenize run-local identity ({{sessionId}}/{{cwd}}, // written by recordFixture); realize both for this world before parsing. const realized = fixtureText .split('{{sessionId}}').join(id) - .split('{{cwd}}').join(harness.workspaceCwd) + .split('{{cwd}}').join(scaffold.workspaceCwd) const fixtureCwd = (JSON.parse(realized.split('\n', 1)[0]!) as { cwd?: string }).cwd const rewritten = fixtureCwd === undefined ? realized - : realized.split(fixtureCwd).join(harness.workspaceCwd) + : realized.split(fixtureCwd).join(scaffold.workspaceCwd) const events = parseSessionLog(rewritten) if (events.length === 0) throw new Error('seed fixture has no events') const last = events[events.length - 1]! @@ -305,7 +305,7 @@ export async function seedSession(harness: WebHarness, fixtureText: string, id: version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt: Date.now() - 60_000, - cwd: harness.workspaceCwd, + cwd: scaffold.workspaceCwd, delegationDepth: 0, } const ctx = new Context() @@ -313,7 +313,7 @@ export async function seedSession(harness: WebHarness, fixtureText: string, id: await ctx.plugin(SessionStore) // Same root as the host with the plugin's own default compression, so the // host's directory-scan list() sees one consistent encoding. - await ctx.plugin(SessionPersistenceJsonl, { root: harness.persistenceRoot }) + await ctx.plugin(SessionPersistenceJsonl, { root: scaffold.persistenceRoot }) await ctx.sessionPersistence.create(meta) await ctx.sessionPersistence.append(meta.id, events) // Deterministic sidebar order: cold summaries take updatedAt from mtime. diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 734c0848c4..67bb5131c5 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -15,8 +15,8 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { join } from 'node:path' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebHarness, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebHarness, -} from './harness.ts' + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' import { saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/seeded-history', import.meta.url)) @@ -28,44 +28,44 @@ const SEED_ID = 'seeded-history-web-e2e' const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' describe('web e2e: seeded history renders through cold resume', () => { - let harness: WebHarness + let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType beforeAll(async () => { - harness = await launchWebHarness({}) + scaffold = await launchWebScaffold({}) // The read-tool targets exist in both modes: record needs them for the // live turn; replay's seeded log carries their recorded contents but the - // workspace stays consistent for any user poking the harness. - await writeFile(join(harness.workspaceCwd, 'a.txt'), 'alpha\n') - await writeFile(join(harness.workspaceCwd, 'b.txt'), 'beta\n') + // workspace stays consistent for any user poking the scaffold. + await writeFile(join(scaffold.workspaceCwd, 'a.txt'), 'alpha\n') + await writeFile(join(scaffold.workspaceCwd, 'b.txt'), 'beta\n') if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - await seedSession(harness, raw, SEED_ID) + await seedSession(scaffold, raw, SEED_ID) } browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 1680, height: 1000 } }) tripwire = watchConsole(page) - await page.goto(harness.baseUrl, { waitUntil: 'load' }) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) afterAll(async () => { await browser?.close() - await harness?.close() + await scaffold?.close() }) it.skipIf(MODE !== 'record')('records the seed turn live through the composer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-record')) const input = page.locator('textarea').first() await input.waitFor({ timeout: 10_000 }) - const settled = harness.whenTurnSettled() + const settled = scaffold.whenTurnSettled() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled - await recordFixture(harness, sessionId, SEED) + await recordFixture(scaffold, sessionId, SEED) }, 200_000) it.skipIf(MODE === 'record')('lists the seeded session cold and renders its history from the log', async () => { @@ -89,17 +89,34 @@ describe('web e2e: seeded history renders through cold resume', () => { it.skipIf(MODE === 'record')('matches the historical conversation aria golden', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-aria')) - const snapshot = (await captureStableAria(page, '[class*="centerCol"]', harness.workspaceCwd)) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) }) + it.skipIf(MODE === 'record')('expands and collapses a tool row rebuilt from the cold log', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-seeded-toolrow')) + // Interaction over cold-resumed history: read rows are expand-in-place + // rows (rowExpands routes the click to toggleExpand, not openDetails), so + // the gesture under test is the inline fold over log-rebuilt content. + // Runs after the golden capture; still zero model calls. + const row = page.locator('[data-variant] [data-clickable][role="button"]').first() + await row.waitFor({ timeout: 10_000 }) + expect(await row.getAttribute('aria-expanded')).toBe('false') + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('true') + // The expanded body renders the recorded tool result (a.txt's contents). + await expect.poll(() => page.getByText('alpha', { exact: false }).count(), { timeout: 5_000 }).toBeGreaterThan(0) + await row.click() + await expect.poll(() => row.getAttribute('aria-expanded'), { timeout: 5_000 }).toBe('false') + }) + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => { // No replay fixture was installed and the llm seam is open — any stray // stream would have failed the turn loudly. Cleanliness pins the wire. expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - expect(harness.serverErrors).toEqual([]) + expect(scaffold.serverErrors).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['seed.jsonl', 'ui.expected.md']) }) }) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 514cbe4d57..e1b68a4309 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -17,12 +17,12 @@ "src", "tests" ], - // The web e2e lane (harness + replay specs) boots the host spine and reads + // The web e2e lane (scaffold + replay specs) boots the host spine and reads // its Context merges — host-plane programs, checked in tsconfig.host.json; // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ - "tests/harness.ts", + "tests/scaffold.ts", "tests/replay-round-trip.e2e.ts", "tests/seeded-history.e2e.ts" ], diff --git a/packages/host/runtime/README.md b/packages/host/runtime/README.md index 4384f591a1..90a547c210 100644 --- a/packages/host/runtime/README.md +++ b/packages/host/runtime/README.md @@ -2,7 +2,7 @@ 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 }`. -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 three sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`), headless session-event subscription, and filling a capability seam the boot options deliberately left open (`llm: false` → the embedder installs its own LLM backend, e.g. the keyless web e2e harness's replay); consuming clients must not bypass `api` through it. +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 three sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`), headless session-event subscription, and filling a capability seam the boot options deliberately left open (`llm: false` → the embedder installs its own LLM backend, e.g. the keyless web e2e scaffold's replay); consuming clients must not bypass `api` through it. ## Configuration diff --git a/packages/host/runtime/src/boot.ts b/packages/host/runtime/src/boot.ts index e2f24a98ac..7e89a435db 100644 --- a/packages/host/runtime/src/boot.ts +++ b/packages/host/runtime/src/boot.ts @@ -69,7 +69,7 @@ export interface BootHostOptions { * LLM adapter selection: `'deepseek'` (default) mounts the DeepSeek adapter * (requires an API key at load), `false` mounts no adapter and leaves the * `llm` capability seam open for the embedder to fill on the returned ctx - * (e.g. the keyless web e2e harness installing a replay backend). With + * (e.g. the keyless web e2e scaffold installing a replay backend). With * `false` and nothing filled, the first stream fails loud with NO_ADAPTER — * the earliest resolvable point for an open capability seam. */ diff --git a/tsconfig.host.json b/tsconfig.host.json index a5f48ed22d..a8688ab3fb 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,7 +8,7 @@ "rewriteRelativeImportExtensions": false }, "include": [ - "apps/web/tests/harness.ts", + "apps/web/tests/scaffold.ts", "apps/web/tests/support.ts", "apps/web/tests/replay-round-trip.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", From 13345fdadcd6625d60bdbd7fa61b4206292a53c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:46:04 +0800 Subject: [PATCH 27/70] docs(i18n): re-record web e2e note pair after the scaffold rename The rename commit edited both sides of the bilingual pair but missed the re-record; the pairing gate compares blob hashes and went red. --- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 1f55dfce3e..d759047151 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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-web-gui-browser-e2e-lane.md: 3cabceb9667d3d1c153518d58b8d4c02b0578d20 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 132caa453662f48619aa542c68b59f59b64acd0f +2026-07-24-web-gui-browser-e2e-lane.md: 9bab4d1eb9ec24acae0057143379629c87194d1f +2026-07-24-web-gui-browser-e2e-lane.zh.md: c61aee7ae5c070d1c82dda0ab78d8db44c16b9bf From 1e78054a78f037d2ea58f27cd7e0298f575f51cc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:04:57 +0800 Subject: [PATCH 28/70] ci: retrigger workflows (push event for 7a4cd7857 was dropped by Actions) From e4553deceda22b8088c108c92367366bcd09bd1c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:13:03 +0800 Subject: [PATCH 29/70] docs(i18n): re-record testing.md pair after merging master's bilingual split The merge added the web-browser-snapshot bullet to both sides of the now bilingual docs/testing.md; the pair record needs the post-merge hashes. --- docs/testing.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 8ebdff8c55..e01ada78dd 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.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 -testing.md: fd38fb7b20d76ef48c81c86badcf501f7c0dbd4e -testing.zh.md: 4584492350aefd5d72692093b08c0dcd4910a8af +testing.md: bf202c23574194d61d138c0f03073136dd29484a +testing.zh.md: 8b98f17c955fbb8cf7588b5bccc2e4c1298d3cf4 From 83cccd7ffc4340251d097684df796178cbafe413 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 25 Jul 2026 08:20:40 +0800 Subject: [PATCH 30/70] feat(llm): add scriptable mock fault server --- ...scriptable-llm-wire-fault-server.i18n.yaml | 6 + ...-07-25-scriptable-llm-wire-fault-server.md | 41 + ...-25-scriptable-llm-wire-fault-server.zh.md | 41 + docs/config-catalog.md | 1 + package.json | 1 + packages/llm/llm-retry/package.json | 3 + .../tests/transport-recovery.spec.ts | 230 ++++++ packages/support/README.md | 3 +- packages/support/llm-mock-server/README.md | 84 ++ packages/support/llm-mock-server/package.json | 45 ++ packages/support/llm-mock-server/src/bin.ts | 50 ++ packages/support/llm-mock-server/src/cli.ts | 212 +++++ packages/support/llm-mock-server/src/index.ts | 723 ++++++++++++++++++ .../support/llm-mock-server/src/invariant.ts | 30 + .../support/llm-mock-server/tests/cli.spec.ts | 121 +++ .../llm-mock-server/tests/invariant.spec.ts | 18 + .../llm-mock-server/tests/server.spec.ts | 312 ++++++++ .../support/llm-mock-server/tsconfig.json | 15 + .../support/llm-mock-server/tsdown.config.ts | 17 + pnpm-lock.yaml | 18 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.host.json | 1 + 22 files changed, 1972 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md create mode 100644 .agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md create mode 100644 packages/llm/llm-retry/tests/transport-recovery.spec.ts create mode 100644 packages/support/llm-mock-server/README.md create mode 100644 packages/support/llm-mock-server/package.json create mode 100644 packages/support/llm-mock-server/src/bin.ts create mode 100644 packages/support/llm-mock-server/src/cli.ts create mode 100644 packages/support/llm-mock-server/src/index.ts create mode 100644 packages/support/llm-mock-server/src/invariant.ts create mode 100644 packages/support/llm-mock-server/tests/cli.spec.ts create mode 100644 packages/support/llm-mock-server/tests/invariant.spec.ts create mode 100644 packages/support/llm-mock-server/tests/server.spec.ts create mode 100644 packages/support/llm-mock-server/tsconfig.json create mode 100644 packages/support/llm-mock-server/tsdown.config.ts diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml new file mode 100644 index 0000000000..4eea19c9c0 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.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-25-scriptable-llm-wire-fault-server.md: 92f7d6aad8e7b4dc8bb08e98bb5847ff27470229 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: 2f5fcc1321b0e4f501f3814e5e96d4b26cf18ec6 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md new file mode 100644 index 0000000000..92f7d6aad8 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md @@ -0,0 +1,41 @@ +# Agent Note: Scriptable LLM wire fault server + +Status: implemented + +English | [中文](2026-07-25-scriptable-llm-wire-fault-server.zh.md) + +## Problem + +Adapter unit tests use local HTTP servers to classify individual provider failures, while retry tests use an in-process scripted `LlmAdapter` to prove closed-step recovery. Neither boundary provides a reusable server for running the shipping HTTP adapter, agent loop, and retry policy together, and neither lets a developer point an existing app at deterministic transport faults by changing only its base URL and API key. + +Connection refusal, a reset before the first event, clean EOF without `[DONE]`, a valid content-less completion, and a reset after partial output have different adapter and recovery outcomes. Treating them as one generic mock failure hides whether the provider boundary preserved the distinction and whether failed chunks remained outside committed model history. + +## Decision + +`@deepseek-ai/dsh-llm-mock-server` is a support package with an importable Node HTTP server and a standalone CLI. It accepts OpenAI-compatible root and `/v1` chat-completions paths, validates an optional bearer token, captures requests, and consumes one explicit behavior per accepted request. Script exhaustion fails loud; repetition requires `repeatLast`. + +Request behaviors cover socket reset, post-header disconnect, partial disconnect, stall, valid empty completion, clean truncated streams, malformed payloads, representative HTTP failures, complete text/reasoning/tool-call responses, slow streaming, and max-token completion. A true `connection_refused` is a CLI listener-lifecycle phase because a bound request handler cannot refuse its own TCP connection. + +The `random` script entry performs a new weighted selection for every request. The server exposes and logs its unsigned 32-bit seed, accepts caller-supplied relative weights, and ships a success-heavy stress profile that mixes transport, protocol, provider, timeout, and semantic-empty outcomes. The profile is configurable test pressure rather than an estimate of production incident frequency; `connection_refused` remains outside the request-level pool. + +The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, and idle timeout recover under the existing default policy; a valid content-less completion succeeds without retry; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. + +## Verification + +Package tests exercise every request behavior, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing, random seed reproducibility, weight validation, telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, empty-success semantics, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. + +## Alternatives considered + +**Implement the server in Python** — rejected because Node's standard HTTP and socket APIs expose every required fault, while TypeScript keeps the server, CLI parser, tests, package build, lint, and coverage inside the repository's existing toolchain. A second runtime would add environment and subprocess dependencies without increasing wire isolation. + +**Keep separate inline mock servers in adapter tests** — rejected because those fixtures cannot be launched by an existing app and would duplicate behavior sequencing, randomization, telemetry, and connection cleanup across suites. A support package gives tests a shared implementation without promoting it to product API. + +**Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise. + +**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Adding `STREAM_CLOSED` or semantic-empty recovery requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. + +## Consequences + +Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and successful empty completions without splicing attempts or modifying model history. + +The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md new file mode 100644 index 0000000000..2f5fcc1321 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 可脚本控制的 LLM(大语言模型)协议层故障服务器 + +Status: implemented + +[English](2026-07-25-scriptable-llm-wire-fault-server.md) | 中文 + +## 问题 + +适配器单元测试使用本地 HTTP 服务器对各类提供方故障逐一分类,重试测试则使用进程内的脚本化 `LlmAdapter` 证明已关闭步骤的恢复能力。这两个边界都无法提供可复用的服务器,以便同时运行交付版本的 HTTP 适配器、agent loop(智能体循环)和重试策略;开发者也无法仅修改现有应用的 base URL 与 API key,就让应用连接到确定性的传输故障。 + +连接遭拒、首个事件前连接被重置、未收到 `[DONE]` 即正常 EOF、合法但无内容的完成,以及输出部分内容后连接被重置,会产生不同的适配器与恢复结果。把它们统一视为普通 mock 故障,会掩盖提供方边界是否保留了这些区别,以及失败请求的分片是否确实没有进入已提交的模型历史。 + +## 决策 + +`@deepseek-ai/dsh-llm-mock-server` 是一个支持包(package),提供可导入的 Node HTTP 服务器和独立 CLI(命令行界面)。它接受兼容 OpenAI 的根路径和 `/v1` chat-completions 路径,校验可选的 bearer token,捕获请求,并对每个已接受请求消耗一个显式行为。脚本耗尽时快速失败;只有设置 `repeatLast` 才会重复最后一个行为。 + +请求行为覆盖 socket 重置、发送 header 后断开、发送部分内容后断开、停滞、合法空完成、正常关闭但被截断的流、畸形 payload、典型 HTTP 故障、完整的文本/推理/工具调用响应、慢速流式输出以及达到 token 上限的完成。真正的 `connection_refused` 由 CLI 的监听器生命周期阶段实现,因为已经绑定端口的请求处理器无法拒绝自身的 TCP 连接。 + +脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。 + +服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置以及空闲超时均可恢复;合法的无内容完成无需重试即可成功;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 + +## 验证 + +包测试覆盖所有请求行为、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析、随机 seed 可复现性、权重校验、遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、空完成成功语义、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 + +## 曾考虑的替代方案 + +**使用 Python 实现服务器**:不予采纳。Node 的标准 HTTP 与 socket API 足以暴露所有所需故障,而 TypeScript 可以让服务器、CLI 解析器、测试、包构建、lint 和覆盖率全部留在仓库现有工具链中。引入第二套运行时会增加环境与子进程依赖,却不能增强协议隔离。 + +**在适配器测试中继续使用各自独立的内联 mock 服务器**:不予采纳。这些 fixture(测试前置数据)无法作为独立服务器启动并供现有应用连接,还会让不同测试套件重复实现行为编排、随机化、遥测和连接清理。支持包让测试共享同一套实现,又不会将其提升为产品 API。 + +**仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。 + +**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否为 `STREAM_CLOSED` 或语义空结果增加恢复能力,需要单独决策,并权衡成本、延迟和重复生成风险。 + +## 后果 + +开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与成功空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 + +服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49a0a1510f..ed0969a053 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1953,6 +1953,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-host-runtime` ([`packages/host/runtime/src/index.ts`](../packages/host/runtime/src/index.ts)) - `@deepseek-ai/dsh-host-webserver` ([`packages/host/webserver/src/index.ts`](../packages/host/webserver/src/index.ts)) - `@deepseek-ai/dsh-jsonrpc-demo` ([`packages/examples/jsonrpc-demo/src/index.ts`](../packages/examples/jsonrpc-demo/src/index.ts)) +- `@deepseek-ai/dsh-llm-mock-server` ([`packages/support/llm-mock-server/src/index.ts`](../packages/support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/support/loader-smoke/src/index.ts`](../packages/support/loader-smoke/src/index.ts)) - `@deepseek-ai/dsh-paths` ([`packages/util/paths/src/index.ts`](../packages/util/paths/src/index.ts)) - `@deepseek-ai/dsh-retention` ([`packages/util/retention/src/index.ts`](../packages/util/retention/src/index.ts)) diff --git a/package.json b/package.json index 3ff149b80a..fb1a56c29d 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "demo:cordis": "node --import tsx packages/examples/tui-demo/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", + "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 6d6c27636c..64cd601b05 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -41,8 +41,11 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-mock-server": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts new file mode 100644 index 0000000000..790e3e22f7 --- /dev/null +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -0,0 +1,230 @@ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as Retry from '../src/index.ts' + +let context: Context | undefined +const servers: MockLlmServer[] = [] + +afterEach(async () => { + await context?.fiber.dispose() + context = undefined + await Promise.all(servers.splice(0).map(server => server.close())) +}) + +async function start( + sequence: readonly MockLlmBehavior[], + options: Omit[0], 'sequence'> = {}, +): Promise { + const server = await startMockLlmServer({ sequence, ...options }) + servers.push(server) + return server +} + +async function harness( + baseURL: string, + options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {}, +): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LlmDeepSeek, { + apiKey: 'mock-key', + baseURL, + streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000, + }) + await ctx.plugin(Retry, { + maxTransientRetries: 2, + initialDelayMs: options.initialDelayMs ?? 10, + maxDelayMs: options.initialDelayMs ?? 10, + jitterRatio: 0, + }) + await ctx.plugin(AgentLoop, { agents: [] }) + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject !== agent || status !== 'idle') return + dispose() + resolve() + }) + }) +} + +function sendAndWait(ctx: Context, agent: Agent): Promise { + const idle = waitForIdle(ctx, agent) + agent.followup([{ type: 'text', text: 'recover through the provider boundary' }]) + return idle +} + +function finalAssistantText(agent: Agent): string | undefined { + const message = agent.session.deriveMessages().at(-1) + if (message?.role !== 'assistant') return undefined + return message.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') +} + +async function unusedPort(): Promise { + const server = createServer() + await new Promise((resolve) => { server.listen(0, '127.0.0.1', resolve) }) + const port = (server.address() as AddressInfo).port + await new Promise((resolve) => { server.close(() => { resolve() }) }) + return port +} + +describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { + it('recovers from a true refused connection after the endpoint starts during backoff', async () => { + const port = await unusedPort() + context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 }) + const agent = context.agentLoop.create(SessionId('wire-refused'), { + provider: 'deepseek', + model: 'mock-model', + }) + let recoveryServer: Promise | undefined + context.on('session/event', (session, event) => { + if (session !== agent.session || event.type !== 'llm/retry' || event.data.retry !== 1) return + recoveryServer = start(['success'], { port, apiKey: 'mock-key', successText: 'connected after retry' }) + }) + + await sendAndWait(context, agent) + const server = await recoveryServer + + expect(server).toBeDefined() + expect(server?.requests).toHaveLength(1) + expect(agent.session.events.filter(event => event.type === 'step/start').map(event => event.data.step)) + .toEqual([1, 2]) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + .toEqual(['TRANSPORT']) + expect(finalAssistantText(agent)).toBe('connected after retry') + }) + + it.each([ + ['stream_disconnect', 0] as const, + ['partial_disconnect', 2] as const, + ])('retries %s without committing failed chunks', async (behavior, failedChunkCount) => { + const server = await start([behavior, 'success'], { + apiKey: 'mock-key', + partialText: 'discard me', + chunkSize: 100, + disconnectDelayMs: 20, + successText: 'recovered response', + }) + context = await harness(server.baseURL) + const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), { + provider: 'deepseek', + model: 'mock-model', + }) + + await sendAndWait(context, agent) + + expect(server.requests).toHaveLength(2) + expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) + expect(agent.session.events.filter(event => + event.type === 'assistant/chunk' && event.data.step === 1, + )).toHaveLength(failedChunkCount) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + .toEqual(['TRANSPORT']) + expect(finalAssistantText(agent)).toBe('recovered response') + }) + + it('treats a wire-valid content-less completion as success without retrying', async () => { + const server = await start(['empty', 'success'], { apiKey: 'mock-key' }) + context = await harness(server.baseURL) + const agent = context.agentLoop.create(SessionId('wire-empty'), { + provider: 'deepseek', + model: 'mock-model', + }) + + await sendAndWait(context, agent) + + expect(server.requests).toHaveLength(1) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ + data: { turn: 1, step: 1, content: [] }, + }) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'completed' } }, + }) + expect(finalAssistantText(agent)).toBeUndefined() + }) + + it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => { + const server = await start(['partial_eof', 'success'], { + apiKey: 'mock-key', + partialText: 'discarded clean eof', + chunkSize: 100, + }) + context = await harness(server.baseURL) + const agent = context.agentLoop.create(SessionId('wire-partial-eof'), { + provider: 'deepseek', + model: 'mock-model', + }) + + await sendAndWait(context, agent) + + expect(server.requests).toHaveLength(1) + expect(agent.session.events.filter(event => + event.type === 'assistant/chunk' && event.data.step === 1, + )).toHaveLength(2) + expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false) + expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { code: 'STREAM_CLOSED' } } }, + }) + }) + + it('turns a stalled body into TIMEOUT and succeeds on the next request', async () => { + const server = await start(['stall', 'success'], { + apiKey: 'mock-key', + successText: 'recovered after timeout', + }) + context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 }) + const agent = context.agentLoop.create(SessionId('wire-stall'), { + provider: 'deepseek', + model: 'mock-model', + }) + + await sendAndWait(context, agent) + + expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success']) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + .toEqual(['TIMEOUT']) + expect(finalAssistantText(agent)).toBe('recovered after timeout') + }) + + it('stops after the configured transport retry budget is exhausted', async () => { + const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], { + apiKey: 'mock-key', + }) + context = await harness(server.baseURL) + const agent = context.agentLoop.create(SessionId('wire-exhausted'), { + provider: 'deepseek', + model: 'mock-model', + }) + + await sendAndWait(context, agent) + + expect(server.requests).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(3) + expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2) + expect(agent.session.events.at(-1)).toMatchObject({ + type: 'turn/end', + data: { reason: { kind: 'error', failure: { code: 'TRANSPORT' } } }, + }) + }) +}) diff --git a/packages/support/README.md b/packages/support/README.md index 045b69d390..31a64aa357 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -8,6 +8,7 @@ Packages that exist to serve development, testing, and the examples rather than | `agent-loop-testkit/` | Shared prerequisite mounting for tests that exercise the concrete agent loop | (library — imported by AgentLoop integration tests) | | `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) | | `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) | +| `llm-mock-server/` | Scriptable OpenAI-compatible HTTP/SSE fault server + CLI for LLM recovery tests | (standalone server and test library) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | -`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate, while `llm-mock-server` drives real provider adapters through deterministic HTTP/SSE faults. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel real-Loader launch boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md new file mode 100644 index 0000000000..20efe731d5 --- /dev/null +++ b/packages/support/llm-mock-server/README.md @@ -0,0 +1,84 @@ +# `@deepseek-ai/dsh-llm-mock-server` + +A scriptable OpenAI-compatible HTTP/SSE server for exercising real LLM adapters, the agent loop, and recovery policy without a provider key. It accepts `POST /chat/completions` and `POST /v1/chat/completions`; each accepted request consumes one configured behavior in arrival order. Invalid methods, paths, bearer tokens, and JSON do not consume the script. + +The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections. + +## Standalone use + +Run the source entry from this repository: + +```sh +pnpm run mock:llm -- \ + --port 8000 \ + --api-key mock-key \ + --sequence partial_disconnect,success \ + --partial-text "discard this half" +``` + +Point the shipping DeepSeek adapter at the server; it appends `/chat/completions` to the configured base: + +```sh +DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ +DEEPSEEK_API_KEY=mock-key \ +pnpm run demo:headless "test provider recovery" +``` + +The built package also exposes `dsh-llm-mock-server`. Stdout is JSONL: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. + +## Behavior script + +`--sequence` is a comma-separated FIFO. Exhaustion returns a structured HTTP 500; `--repeat-last` explicitly reuses the last entry. + +| Behavior | Wire result | +|---|---| +| `connection_reset` | Destroy the socket before HTTP headers | +| `stream_disconnect` | Send SSE headers, then reset before the first event | +| `partial_disconnect` | Send text deltas, then reset the socket | +| `stall` | Send SSE headers and remain idle until client/server cancellation | +| `empty` | Send a valid content-less stop and `[DONE]` | +| `empty_body` / `stream_eof` / `partial_eof` | End cleanly without the required `[DONE]` boundary | +| `malformed_json` / `malformed_event` | Send invalid SSE JSON or an invalid provider chunk shape | +| `rate_limit` / `server_error` / `service_unavailable` | Return retry-oriented 429/500/503 JSON errors | +| `auth_error` / `invalid_request` / `context_overflow` / `quota_exceeded` | Return terminal or separately recovered provider errors | +| `success` / `slow_success` / `reasoning_success` | Stream a complete text response, optionally delayed or preceded by reasoning | +| `tool_call_success` / `max_tokens` | Complete with a tool call or `length` finish | +| `wrong_content_type` | Send a valid SSE body under `application/json` | +| `random` | Select a concrete request behavior from weighted seeded randomness | + +`connection_refused` is CLI-only and must be the first entry. It delays binding a caller-specified nonzero port, so requests during `--listen-delay-ms` receive a real TCP refusal; the remaining entries begin after the listener starts. + +## Random mode + +Use a repeating `random` entry for an open-ended mixed run: + +```sh +pnpm run mock:llm -- \ + --port 8000 \ + --sequence random \ + --repeat-last \ + --seed 42 \ + --random-weights 'success=60,slow_success=10,connection_reset=5,stream_disconnect=5,partial_disconnect=10,empty=5,server_error=5' +``` + +Omitting `--seed` generates one and prints it in the `ready` record. `--random-weights` accepts non-negative relative `behavior=weight` entries and requires at least one positive concrete behavior. The exported default is a success-heavy stress profile containing reset, disconnect, partial output, empty completion, stall, 429/5xx, clean truncation, and malformed JSON; it is test pressure, not an estimate of production incident frequency. `connection_refused` is excluded because a bound request handler cannot produce a true refusal. + +When random weights include `stall`, configure the client under test with a short stream-idle timeout so the scenario terminates promptly. + +## Timing and content controls + +The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer `; omission accepts any token. + +## Model Experience + +None, as this test server substitutes provider wire behavior without invoking a real model. + +#### KV Cache effect + +None; requests terminate locally and never reach a provider cache. + +## Known Limitations and Deferred Work + +- **Random weights model test pressure, not production incidence** — callers that want an environment-specific distribution must provide measured weights and record the emitted seed. +- **Request scripts are arrival-ordered** — concurrent callers share one cursor, so deterministic per-session fault assignment requires separate server instances. +- **True connection refusal is a listener lifecycle phase** — the CLI delay must overlap the client attempt; request-level random selection can only reset an accepted connection. diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json new file mode 100644 index 0000000000..790365407b --- /dev/null +++ b/packages/support/llm-mock-server/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-llm-mock-server", + "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-llm-mock-server": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "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" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/support/llm-mock-server/src/bin.ts b/packages/support/llm-mock-server/src/bin.ts new file mode 100644 index 0000000000..e77de74dad --- /dev/null +++ b/packages/support/llm-mock-server/src/bin.ts @@ -0,0 +1,50 @@ +#!/usr/bin/env node +/** + * Standalone process wrapper for the scriptable mock LLM server. + * @module @deepseek-ai/dsh-llm-mock-server/bin + */ + +import { setTimeout as delay } from 'node:timers/promises' +import { MOCK_LLM_CLI_USAGE, parseMockLlmCliArgs } from './cli.ts' +import { startMockLlmServer } from './index.ts' + +/* v8 ignore start -- thin process/signal glue; parser and server behavior are covered directly */ +try { + const parsed = parseMockLlmCliArgs(process.argv.slice(2)) + if (parsed.kind === 'help') { + process.stdout.write(MOCK_LLM_CLI_USAGE) + } else { + const { server: serverOptions, listenDelayMs, startsUnavailable } = parsed.config + const host = serverOptions.host ?? '127.0.0.1' + const port = serverOptions.port ?? 8_000 + if (startsUnavailable) { + process.stdout.write(`${JSON.stringify({ + type: 'unavailable', + baseURL: `http://${host}:${port}/v1`, + listenDelayMs, + })}\n`) + await delay(listenDelayMs) + } + const server = await startMockLlmServer({ + ...serverOptions, + onEvent: (event) => { process.stdout.write(`${JSON.stringify(event)}\n`) }, + }) + process.stdout.write(`${JSON.stringify({ + type: 'ready', + baseURL: `${server.baseURL}/v1`, + randomSeed: server.randomSeed, + })}\n`) + let closing = false + const close = (code: number): void => { + if (closing) return + closing = true + void server.close().finally(() => { process.exit(code) }) + } + process.on('SIGINT', () => { close(130) }) + process.on('SIGTERM', () => { close(143) }) + } +} catch (error: unknown) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n\n${MOCK_LLM_CLI_USAGE}`) + process.exitCode = 1 +} +/* v8 ignore stop */ diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts new file mode 100644 index 0000000000..12d10072f2 --- /dev/null +++ b/packages/support/llm-mock-server/src/cli.ts @@ -0,0 +1,212 @@ +/** + * Dependency-free CLI parsing for the standalone mock LLM server. + * @module @deepseek-ai/dsh-llm-mock-server/cli + */ + +import { MOCK_LLM_BEHAVIORS } from './index.ts' +import type { + ConcreteMockLlmBehavior, + MockLlmBehavior, + MockLlmRandomWeights, + MockLlmServerOptions, +} from './index.ts' + +/** Listener lifecycle behavior understood only by the standalone CLI. */ +export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused' + +/** Parsed CLI configuration, including a pre-listen unavailable interval. */ +export interface MockLlmCliConfig { + /** Server options after removing the lifecycle-only `connection_refused` entry. */ + readonly server: MockLlmServerOptions + /** Delay before binding the model port; zero starts immediately. */ + readonly listenDelayMs: number + /** Whether the original sequence requested a true pre-listen refusal phase. */ + readonly startsUnavailable: boolean +} + +/** Result of parsing `dsh-llm-mock-server` arguments. */ +export type MockLlmCliParseResult = + | { readonly kind: 'help' } + | { readonly kind: 'run'; readonly config: MockLlmCliConfig } + +const BEHAVIORS = new Set(MOCK_LLM_BEHAVIORS) +const DEFAULT_LISTEN_DELAY_MS = 750 + +/** Command usage written for `--help` and invalid arguments. */ +export const MOCK_LLM_CLI_USAGE = `Usage: dsh-llm-mock-server [options] + +Required: + --sequence Ordered behaviors; connection_refused is allowed first + +Listener: + --host Default 127.0.0.1 + --port Default 8000; required and nonzero for connection_refused + --api-key Validate exact Bearer token when present + --listen-delay-ms Unavailable interval (default 750 with connection_refused) + --repeat-last Repeat the final request behavior after exhaustion + --seed Reproduce random selections + --random-weights Relative weights for concrete behaviors + +Response: + --success-text + --partial-text + --reasoning-text + --chunk-size + --chunk-delay-ms + --disconnect-delay-ms + --retry-after-ms + --request-id + --tool-name + --tool-arguments + +Other: + --help +` + +function optionValue(argv: readonly string[], index: number, option: string): string { + const value = argv[index + 1] + if (value === undefined || value.startsWith('--')) { + throw new Error(`dsh-llm-mock-server: ${option} requires a value`) + } + return value +} + +function numberValue(option: string, value: string): number { + const parsed = Number(value) + if (!Number.isFinite(parsed)) throw new Error(`dsh-llm-mock-server: ${option} must be a finite number`) + return parsed +} + +function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } { + const entries = raw.split(',').map(entry => entry.trim()) + if (entries.some(entry => entry.length === 0)) { + throw new Error('dsh-llm-mock-server: --sequence must contain non-empty comma-separated behaviors') + } + const startsUnavailable = entries[0] === CONNECTION_REFUSED_BEHAVIOR + if (entries.slice(1).includes(CONNECTION_REFUSED_BEHAVIOR)) { + throw new Error('dsh-llm-mock-server: connection_refused is allowed only as the first behavior') + } + const requestEntries = startsUnavailable ? entries.slice(1) : entries + if (requestEntries.length === 0) { + throw new Error('dsh-llm-mock-server: connection_refused must be followed by a request behavior') + } + for (const entry of requestEntries) { + if (!BEHAVIORS.has(entry)) throw new Error(`dsh-llm-mock-server: unknown behavior ${JSON.stringify(entry)}`) + } + return { startsUnavailable, sequence: requestEntries as MockLlmBehavior[] } +} + +function parseRandomWeights(raw: string): MockLlmRandomWeights { + const weights: MockLlmRandomWeights = {} + for (const entry of raw.split(',')) { + const [behavior, rawWeight, ...extra] = entry.split('=') + if (behavior === undefined || behavior === '' || rawWeight === undefined || rawWeight === '' || extra.length > 0) { + throw new Error('dsh-llm-mock-server: --random-weights expects behavior=weight comma-separated entries') + } + if (!BEHAVIORS.has(behavior) || behavior === 'random') { + throw new Error(`dsh-llm-mock-server: random weight requires a concrete behavior, got ${JSON.stringify(behavior)}`) + } + if (Object.hasOwn(weights, behavior)) { + throw new Error(`dsh-llm-mock-server: duplicate random weight for ${JSON.stringify(behavior)}`) + } + weights[behavior as ConcreteMockLlmBehavior] = numberValue('--random-weights', rawWeight) + } + return weights +} + +/** + * Parse standalone server arguments without starting a process or listener. + * @param argv - arguments after the executable name. + * @returns help or validated run configuration. + */ +export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseResult { + if (argv.includes('--help')) return { kind: 'help' } + + let sequenceRaw: string | undefined + let host: string | undefined + let port = 8_000 + let apiKey: string | undefined + let listenDelayMs: number | undefined + let repeatLast = false + let randomSeed: number | undefined + let randomWeights: MockLlmRandomWeights | undefined + let successText: string | undefined + let partialText: string | undefined + let reasoningText: string | undefined + let chunkSize: number | undefined + let chunkDelayMs: number | undefined + let disconnectDelayMs: number | undefined + let retryAfterMs: number | undefined + let requestId: string | undefined + let toolName: string | undefined + let toolArguments: string | undefined + + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index] as string + if (option === '--repeat-last') { + repeatLast = true + continue + } + const value = optionValue(argv, index, option) + index += 1 + switch (option) { + case '--sequence': sequenceRaw = value; break + case '--host': host = value; break + case '--port': port = numberValue(option, value); break + case '--api-key': apiKey = value; break + case '--listen-delay-ms': listenDelayMs = numberValue(option, value); break + case '--seed': randomSeed = numberValue(option, value); break + case '--random-weights': randomWeights = parseRandomWeights(value); break + case '--success-text': successText = value; break + case '--partial-text': partialText = value; break + case '--reasoning-text': reasoningText = value; break + case '--chunk-size': chunkSize = numberValue(option, value); break + case '--chunk-delay-ms': chunkDelayMs = numberValue(option, value); break + case '--disconnect-delay-ms': disconnectDelayMs = numberValue(option, value); break + case '--retry-after-ms': retryAfterMs = numberValue(option, value); break + case '--request-id': requestId = value; break + case '--tool-name': toolName = value; break + case '--tool-arguments': toolArguments = value; break + default: throw new Error(`dsh-llm-mock-server: unknown option ${JSON.stringify(option)}`) + } + } + + if (sequenceRaw === undefined) throw new Error('dsh-llm-mock-server: --sequence is required') + const parsedSequence = parseSequence(sequenceRaw) + if (parsedSequence.startsUnavailable && port === 0) { + throw new Error('dsh-llm-mock-server: connection_refused requires an explicit nonzero --port') + } + if (!parsedSequence.startsUnavailable && listenDelayMs !== undefined) { + throw new Error('dsh-llm-mock-server: --listen-delay-ms requires connection_refused first in --sequence') + } + if (!parsedSequence.sequence.includes('random') && (randomSeed !== undefined || randomWeights !== undefined)) { + throw new Error('dsh-llm-mock-server: --seed and --random-weights require random in --sequence') + } + + return { + kind: 'run', + config: { + server: { + sequence: parsedSequence.sequence, + port, + repeatLast, + ...randomSeed === undefined ? {} : { randomSeed }, + ...randomWeights === undefined ? {} : { randomWeights }, + ...host === undefined ? {} : { host }, + ...apiKey === undefined ? {} : { apiKey }, + ...successText === undefined ? {} : { successText }, + ...partialText === undefined ? {} : { partialText }, + ...reasoningText === undefined ? {} : { reasoningText }, + ...chunkSize === undefined ? {} : { chunkSize }, + ...chunkDelayMs === undefined ? {} : { chunkDelayMs }, + ...disconnectDelayMs === undefined ? {} : { disconnectDelayMs }, + ...retryAfterMs === undefined ? {} : { retryAfterMs }, + ...requestId === undefined ? {} : { requestId }, + ...toolName === undefined ? {} : { toolName }, + ...toolArguments === undefined ? {} : { toolArguments }, + }, + listenDelayMs: parsedSequence.startsUnavailable ? listenDelayMs ?? DEFAULT_LISTEN_DELAY_MS : 0, + startsUnavailable: parsedSequence.startsUnavailable, + }, + } +} diff --git a/packages/support/llm-mock-server/src/index.ts b/packages/support/llm-mock-server/src/index.ts new file mode 100644 index 0000000000..45dda839e2 --- /dev/null +++ b/packages/support/llm-mock-server/src/index.ts @@ -0,0 +1,723 @@ +/** + * Scriptable OpenAI-compatible HTTP/SSE server for transport, protocol, and + * semantic-empty LLM recovery tests. Each accepted chat-completions request + * consumes one behavior; the server never retries or interprets harness policy. + * + * @module @deepseek-ai/dsh-llm-mock-server + */ + +import { createServer } from 'node:http' +import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http' +import { randomBytes } from 'node:crypto' +import type { AddressInfo } from 'node:net' +import { setTimeout as delay } from 'node:timers/promises' + +/** Request-scoped behaviors accepted by {@link startMockLlmServer}. */ +export const MOCK_LLM_BEHAVIORS = [ + 'connection_reset', + 'stream_disconnect', + 'empty', + 'empty_body', + 'stream_eof', + 'partial_eof', + 'partial_disconnect', + 'stall', + 'malformed_json', + 'malformed_event', + 'wrong_content_type', + 'rate_limit', + 'server_error', + 'service_unavailable', + 'auth_error', + 'invalid_request', + 'context_overflow', + 'quota_exceeded', + 'success', + 'reasoning_success', + 'tool_call_success', + 'max_tokens', + 'slow_success', + 'random', +] as const + +/** One scripted mock behavior name; `random` selects a concrete behavior per request. */ +export type MockLlmBehavior = typeof MOCK_LLM_BEHAVIORS[number] + +/** One concrete request behavior after resolving a `random` script entry. */ +export type ConcreteMockLlmBehavior = Exclude + +/** Relative non-negative weights for random request behavior selection. */ +export type MockLlmRandomWeights = Partial> + +/** + * Default stress profile for `random`. Weights are configurable test pressure, + * not a claim about production incident frequency. + */ +export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly = Object.freeze({ + success: 48, + slow_success: 10, + max_tokens: 2, + connection_reset: 5, + stream_disconnect: 5, + partial_disconnect: 10, + empty: 5, + stall: 2, + rate_limit: 5, + server_error: 4, + service_unavailable: 2, + partial_eof: 1, + malformed_json: 1, +}) + +/** How one accepted request ended at the mock boundary. */ +export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error' + +/** Immutable telemetry emitted when a request starts or reaches an outcome. */ +export type MockLlmServerEvent = + | { + readonly type: 'request' + readonly attempt: number + readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' + readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted' + readonly path: string + } + | { + readonly type: 'result' + readonly attempt: number + readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' + readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted' + readonly outcome: MockLlmRequestOutcome + readonly chunksSent: number + } + +/** Captured wire request and its final server-side outcome. */ +export interface MockLlmRequestRecord { + /** One-based accepted chat-completions request number. */ + readonly attempt: number + /** Script entry consumed for this request before random resolution. */ + readonly scriptBehavior: MockLlmBehavior | 'script_exhausted' + /** Concrete behavior selected for this request, or exhaustion after the configured script. */ + readonly behavior: ConcreteMockLlmBehavior | 'script_exhausted' + /** Original request path, including a `/v1` prefix when the client supplied one. */ + readonly path: string + /** Detached request headers. */ + readonly headers: Readonly + /** Parsed JSON request body. */ + readonly body: unknown + /** Number of SSE `data:` events handed to Node before the outcome. */ + chunksSent: number + /** Final server-side outcome; absent while a stalled request remains open. */ + outcome?: MockLlmRequestOutcome +} + +/** Configuration for one mock server instance. */ +export interface MockLlmServerOptions { + /** Loopback host by default. */ + readonly host?: string + /** TCP port; zero requests an OS-assigned port. */ + readonly port?: number + /** Optional exact bearer token; omission accepts any authorization header. */ + readonly apiKey?: string + /** Ordered request behaviors; exhaustion fails loud unless `repeatLast` is true. */ + readonly sequence: readonly MockLlmBehavior[] + /** Reuse the final behavior after the sequence is consumed. */ + readonly repeatLast?: boolean + /** Optional deterministic unsigned 32-bit seed; omission generates and exposes one. */ + readonly randomSeed?: number + /** Relative weights used whenever a script entry is `random`. */ + readonly randomWeights?: Readonly + /** Complete text returned by success-shaped behaviors. */ + readonly successText?: string + /** Text emitted before partial EOF/reset behaviors terminate. */ + readonly partialText?: string + /** Reasoning text emitted by `reasoning_success`. */ + readonly reasoningText?: string + /** Unicode code-point count per text or reasoning SSE delta. */ + readonly chunkSize?: number + /** Inter-chunk delay for `slow_success`, in milliseconds. */ + readonly chunkDelayMs?: number + /** Delay after headers/deltas before a forced disconnect, in milliseconds. */ + readonly disconnectDelayMs?: number + /** Provider retry delay; the wire `Retry-After` value rounds up to whole seconds. */ + readonly retryAfterMs?: number + /** Optional provider request id returned on HTTP failures. */ + readonly requestId?: string + /** Tool name emitted by `tool_call_success`. */ + readonly toolName?: string + /** Raw JSON arguments emitted by `tool_call_success`. */ + readonly toolArguments?: string + /** Optional observer for JSONL CLI telemetry; observer failures never affect wire behavior. */ + readonly onEvent?: (event: MockLlmServerEvent) => void +} + +/** Running mock server and captured request state. */ +export interface MockLlmServer { + /** Base URL without `/v1`; both root and `/v1` chat-completions paths are accepted. */ + readonly baseURL: string + /** Actual bound port, including an OS-assigned value. */ + readonly port: number + /** Seed used for random behavior selection, including the generated default. */ + readonly randomSeed: number + /** Live request records in arrival order. */ + readonly requests: readonly MockLlmRequestRecord[] + /** Stop accepting requests and force-close stalled/streaming connections; idempotent. */ + close(): Promise +} + +interface ResolvedOptions { + readonly host: string + readonly port: number + readonly apiKey?: string + readonly sequence: readonly MockLlmBehavior[] + readonly lastBehavior: MockLlmBehavior + readonly repeatLast: boolean + readonly randomSeed: number + readonly randomWeights: readonly (readonly [ConcreteMockLlmBehavior, number])[] + readonly successText: string + readonly partialText: string + readonly reasoningText: string + readonly chunkSize: number + readonly chunkDelayMs: number + readonly disconnectDelayMs: number + readonly retryAfterMs: number + readonly requestId?: string + readonly toolName: string + readonly toolArguments: string + readonly onEvent?: (event: MockLlmServerEvent) => void +} + +const MAX_TIMER_DELAY_MS = 2_147_483_647 +const DEFAULT_SUCCESS_TEXT = 'mock response recovered' +const DEFAULT_PARTIAL_TEXT = 'discarded partial response' +const DEFAULT_REASONING_TEXT = 'mock reasoning' +const CONCRETE_BEHAVIORS = new Set(MOCK_LLM_BEHAVIORS.filter(behavior => behavior !== 'random')) + +function boundedInteger(name: string, value: number, min: number, max: number): number { + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`llm-mock-server: ${name} must be an integer between ${min} and ${max}`) + } + return value +} + +function resolveOptions(options: MockLlmServerOptions): ResolvedOptions { + const host = options.host ?? '127.0.0.1' + const port = boundedInteger('port', options.port ?? 0, 0, 65_535) + const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER) + const chunkDelayMs = boundedInteger('chunkDelayMs', options.chunkDelayMs ?? 25, 0, MAX_TIMER_DELAY_MS) + const disconnectDelayMs = boundedInteger( + 'disconnectDelayMs', + options.disconnectDelayMs ?? 10, + 0, + MAX_TIMER_DELAY_MS, + ) + const retryAfterMs = boundedInteger('retryAfterMs', options.retryAfterMs ?? 1_000, 1, MAX_TIMER_DELAY_MS) + const randomSeed = boundedInteger( + 'randomSeed', + options.randomSeed ?? randomBytes(4).readUInt32LE(0), + 0, + 0xffff_ffff, + ) + const successText = options.successText ?? DEFAULT_SUCCESS_TEXT + const partialText = options.partialText ?? DEFAULT_PARTIAL_TEXT + const reasoningText = options.reasoningText ?? DEFAULT_REASONING_TEXT + const toolName = options.toolName ?? 'mock_tool' + const toolArguments = options.toolArguments ?? '{"value":"mock"}' + + if (host.length === 0) throw new Error('llm-mock-server: host must not be empty') + if (options.sequence.length === 0) throw new Error('llm-mock-server: sequence must not be empty') + const lastBehavior = options.sequence.reduce((_previous, behavior) => behavior) + if (options.apiKey === '') throw new Error('llm-mock-server: apiKey must not be empty') + if (successText.length === 0) throw new Error('llm-mock-server: successText must not be empty') + if (partialText.length === 0) throw new Error('llm-mock-server: partialText must not be empty') + if (reasoningText.length === 0) throw new Error('llm-mock-server: reasoningText must not be empty') + if (toolName.length === 0) throw new Error('llm-mock-server: toolName must not be empty') + if (options.requestId === '') throw new Error('llm-mock-server: requestId must not be empty') + try { + JSON.parse(toolArguments) + } catch { + throw new Error('llm-mock-server: toolArguments must be valid JSON') + } + + const configuredWeights = options.randomWeights ?? DEFAULT_MOCK_LLM_RANDOM_WEIGHTS + const randomWeights: Array = [] + for (const [behavior, weight] of Object.entries(configuredWeights)) { + if (!CONCRETE_BEHAVIORS.has(behavior)) { + throw new Error(`llm-mock-server: randomWeights contains unknown concrete behavior ${JSON.stringify(behavior)}`) + } + if (!Number.isFinite(weight) || weight < 0) { + throw new Error(`llm-mock-server: random weight for ${behavior} must be a non-negative finite number`) + } + if (weight > 0) randomWeights.push([behavior as ConcreteMockLlmBehavior, weight]) + } + if (randomWeights.length === 0) { + throw new Error('llm-mock-server: randomWeights must contain at least one positive weight') + } + + return { + host, + port, + ...options.apiKey === undefined ? {} : { apiKey: options.apiKey }, + sequence: [...options.sequence], + lastBehavior, + repeatLast: options.repeatLast ?? false, + randomSeed, + randomWeights, + successText, + partialText, + reasoningText, + chunkSize, + chunkDelayMs, + disconnectDelayMs, + retryAfterMs, + ...options.requestId === undefined ? {} : { requestId: options.requestId }, + toolName, + toolArguments, + ...options.onEvent === undefined ? {} : { onEvent: options.onEvent }, + } +} + +function emit(options: ResolvedOptions, event: MockLlmServerEvent): void { + try { + options.onEvent?.(Object.freeze(event)) + } catch (_telemetryObserverFailure) { + // Test telemetry is observational; a broken observer cannot change provider wire behavior. + } +} + +async function readJsonBody(request: IncomingMessage): Promise { + let body = '' + for await (const chunk of request) body += Buffer.from(chunk).toString('utf8') + return body.length === 0 ? undefined : JSON.parse(body) +} + +function splitText(text: string, size: number): string[] { + const points = Array.from(text) + const chunks: string[] = [] + for (let index = 0; index < points.length; index += size) chunks.push(points.slice(index, index + size).join('')) + return chunks +} + +function openSse(response: ServerResponse, contentType = 'text/event-stream; charset=utf-8'): void { + response.writeHead(200, { + 'content-type': contentType, + 'cache-control': 'no-cache', + 'connection': 'keep-alive', + }) + response.flushHeaders() +} + +function writeSse(record: MockLlmRequestRecord, response: ServerResponse, payload: unknown): void { + response.write(`data: ${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n\n`) + record.chunksSent += 1 +} + +function writeDone(record: MockLlmRequestRecord, response: ServerResponse): void { + writeSse(record, response, '[DONE]') +} + +function finishRecord( + options: ResolvedOptions, + record: MockLlmRequestRecord, + outcome: MockLlmRequestOutcome, +): void { + record.outcome = outcome + emit(options, { + type: 'result', + attempt: record.attempt, + scriptBehavior: record.scriptBehavior, + behavior: record.behavior, + outcome, + chunksSent: record.chunksSent, + }) +} + +function httpError( + options: ResolvedOptions, + record: MockLlmRequestRecord, + response: ServerResponse, + status: number, + message: string, + code: string, + type = 'mock_error', +): void { + const headers: Record = { 'content-type': 'application/json' } + if (record.behavior === 'rate_limit') { + headers['retry-after'] = String(Math.ceil(options.retryAfterMs / 1_000)) + } + if (options.requestId !== undefined) headers['x-request-id'] = options.requestId + response.writeHead(status, headers) + response.end(JSON.stringify({ error: { message, type, code } })) + finishRecord(options, record, 'completed') +} + +function terminalChunk(reason: string, outputTokens: number): unknown { + return { + choices: [{ index: 0, delta: { content: '' }, finish_reason: reason }], + usage: { prompt_tokens: 3, completion_tokens: outputTokens }, + } +} + +async function pause(milliseconds: number, response: ServerResponse): Promise { + if (milliseconds === 0) return !response.destroyed + const controller = new AbortController() + const stop = (): void => { controller.abort() } + response.once('close', stop) + try { + await delay(milliseconds, undefined, { signal: controller.signal }) + return true + } catch (_responseClosed) { + // The timer only receives this response-owned abort signal; closing the response cancels its wait. + return false + } finally { + response.off('close', stop) + } +} + +async function streamText( + options: ResolvedOptions, + record: MockLlmRequestRecord, + response: ServerResponse, + text: string, + delayMs: number, +): Promise { + for (const chunk of splitText(text, options.chunkSize)) { + writeSse(record, response, { choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }] }) + if (!await pause(delayMs, response)) return false + } + return true +} + +async function completeText( + options: ResolvedOptions, + record: MockLlmRequestRecord, + response: ServerResponse, + reason: 'stop' | 'length', + delayMs: number, +): Promise { + if (!await streamText(options, record, response, options.successText, delayMs)) { + finishRecord(options, record, 'client_closed') + return + } + writeSse(record, response, terminalChunk(reason, Array.from(options.successText).length)) + writeDone(record, response) + response.end() + finishRecord(options, record, 'completed') +} + +async function disconnect( + options: ResolvedOptions, + record: MockLlmRequestRecord, + response: ServerResponse, +): Promise { + if (!await pause(options.disconnectDelayMs, response)) { + finishRecord(options, record, 'client_closed') + return + } + finishRecord(options, record, 'reset') + response.destroy() +} + +function toolCallChunks(options: ResolvedOptions): readonly unknown[] { + const midpoint = Math.max(1, Math.floor(options.toolArguments.length / 2)) + return [ + { + choices: [{ + index: 0, + delta: { + tool_calls: [{ + index: 0, + id: 'mock-call-1', + type: 'function', + function: { name: options.toolName, arguments: options.toolArguments.slice(0, midpoint) }, + }], + }, + finish_reason: null, + }], + }, + { + choices: [{ + index: 0, + delta: { tool_calls: [{ index: 0, function: { arguments: options.toolArguments.slice(midpoint) } }] }, + finish_reason: null, + }], + }, + ] +} + +async function runBehavior( + options: ResolvedOptions, + record: MockLlmRequestRecord, + request: IncomingMessage, + response: ServerResponse, +): Promise { + switch (record.behavior) { + case 'script_exhausted': + httpError(options, record, response, 500, 'mock script exhausted', 'MOCK_SCRIPT_EXHAUSTED') + return + case 'connection_reset': + finishRecord(options, record, 'reset') + request.socket.destroy() + return + case 'stream_disconnect': + openSse(response) + await disconnect(options, record, response) + return + case 'empty': + openSse(response) + writeSse(record, response, terminalChunk('stop', 0)) + writeDone(record, response) + response.end() + finishRecord(options, record, 'completed') + return + case 'empty_body': + openSse(response) + response.end() + finishRecord(options, record, 'completed') + return + case 'stream_eof': + openSse(response) + writeSse(record, response, { choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] }) + response.end() + finishRecord(options, record, 'completed') + return + case 'partial_eof': + openSse(response) + await streamText(options, record, response, options.partialText, 0) + response.end() + finishRecord(options, record, 'completed') + return + case 'partial_disconnect': + openSse(response) + if (!await streamText(options, record, response, options.partialText, options.chunkDelayMs)) return + await disconnect(options, record, response) + return + case 'stall': + openSse(response) + finishRecord(options, record, 'stalled') + return + case 'malformed_json': + openSse(response) + writeSse(record, response, '{not-json') + writeDone(record, response) + response.end() + finishRecord(options, record, 'completed') + return + case 'malformed_event': + openSse(response) + writeSse(record, response, { choices: [null] }) + writeDone(record, response) + response.end() + finishRecord(options, record, 'completed') + return + case 'wrong_content_type': + openSse(response, 'application/json') + await completeText(options, record, response, 'stop', 0) + return + case 'rate_limit': + httpError(options, record, response, 429, 'mock rate limit', 'rate_limit') + return + case 'server_error': + httpError(options, record, response, 500, 'mock server error', 'server_error') + return + case 'service_unavailable': + httpError(options, record, response, 503, 'mock service unavailable', 'service_unavailable') + return + case 'auth_error': + httpError(options, record, response, 401, 'mock authentication failed', 'invalid_api_key') + return + case 'invalid_request': + httpError(options, record, response, 400, 'mock invalid request', 'invalid_request') + return + case 'context_overflow': + httpError( + options, + record, + response, + 400, + 'mock input exceeds the model context window', + 'context_length_exceeded', + 'invalid_request_error', + ) + return + case 'quota_exceeded': + httpError(options, record, response, 429, 'mock insufficient quota', 'insufficient_quota') + return + case 'success': + openSse(response) + await completeText(options, record, response, 'stop', 0) + return + case 'reasoning_success': + openSse(response) + for (const chunk of splitText(options.reasoningText, options.chunkSize)) { + writeSse(record, response, { + choices: [{ index: 0, delta: { reasoning_content: chunk }, finish_reason: null }], + }) + } + await completeText(options, record, response, 'stop', 0) + return + case 'tool_call_success': + openSse(response) + for (const chunk of toolCallChunks(options)) writeSse(record, response, chunk) + writeSse(record, response, terminalChunk('tool_calls', 2)) + writeDone(record, response) + response.end() + finishRecord(options, record, 'completed') + return + case 'max_tokens': + openSse(response) + await completeText(options, record, response, 'length', 0) + return + case 'slow_success': + openSse(response) + await completeText(options, record, response, 'stop', options.chunkDelayMs) + return + } +} + +function seededRandom(seed: number): () => number { + let state = seed + return () => { + state = (state + 0x6d2b_79f5) >>> 0 + let mixed = state + mixed = Math.imul(mixed ^ mixed >>> 15, mixed | 1) + mixed ^= mixed + Math.imul(mixed ^ mixed >>> 7, mixed | 61) + return ((mixed ^ mixed >>> 14) >>> 0) / 0x1_0000_0000 + } +} + +function chooseRandomBehavior( + weights: readonly (readonly [ConcreteMockLlmBehavior, number])[], + random: () => number, +): ConcreteMockLlmBehavior { + const total = weights.reduce((sum, entry) => sum + entry[1], 0) + let draw = random() * total + for (const [behavior, weight] of weights) { + if (draw < weight) return behavior + draw -= weight + } + // Floating-point subtraction can only leave a rounding residue at the upper boundary. + /* v8 ignore next -- seededRandom is strictly less than one; this guards floating-point residue only */ + return (weights.at(-1) as readonly [ConcreteMockLlmBehavior, number])[0] +} + +/** + * Start a local chat-completions server that consumes one configured behavior + * per accepted request. Only a `POST` path ending in `/chat/completions` consumes the script; + * invalid routes, methods, authorization, and JSON receive ordinary 4xx + * responses. Closing the handle terminates stalled connections. + * + * @param options - listener, script, response content, timing, and telemetry options. + * @returns the listening handle after the port is bound. + */ +export async function startMockLlmServer(options: MockLlmServerOptions): Promise { + const resolved = resolveOptions(options) + const requests: MockLlmRequestRecord[] = [] + const random = seededRandom(resolved.randomSeed) + let cursor = 0 + + const selectBehavior = (): { + scriptBehavior: MockLlmBehavior | 'script_exhausted' + behavior: ConcreteMockLlmBehavior | 'script_exhausted' + } => { + const selected = resolved.sequence[cursor] + cursor += 1 + const scriptBehavior = selected + ?? (resolved.repeatLast ? resolved.lastBehavior : 'script_exhausted') + return { + scriptBehavior, + behavior: scriptBehavior === 'random' + ? chooseRandomBehavior(resolved.randomWeights, random) + : scriptBehavior, + } + } + + const handle = async (request: IncomingMessage, response: ServerResponse): Promise => { + /* v8 ignore next -- node:http server requests always carry a URL despite the shared optional type */ + const path = new URL(request.url ?? '/', 'http://mock.invalid').pathname + if (request.method !== 'POST') { + response.writeHead(405, { allow: 'POST' }).end() + return + } + if (!path.endsWith('/chat/completions')) { + response.writeHead(404).end() + return + } + if (resolved.apiKey !== undefined && request.headers.authorization !== `Bearer ${resolved.apiKey}`) { + response.writeHead(401, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'invalid mock bearer token', code: 'invalid_api_key' } })) + return + } + + let body: unknown + try { + body = await readJsonBody(request) + } catch { + response.writeHead(400, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'request body must be valid JSON', code: 'invalid_json' } })) + return + } + + const selected = selectBehavior() + const record: MockLlmRequestRecord = { + attempt: requests.length + 1, + scriptBehavior: selected.scriptBehavior, + behavior: selected.behavior, + path, + headers: { ...request.headers }, + body, + chunksSent: 0, + } + requests.push(record) + response.once('close', () => { + if (!response.writableFinished && record.outcome === undefined) { + finishRecord(resolved, record, 'client_closed') + } + }) + emit(resolved, { + type: 'request', + attempt: record.attempt, + scriptBehavior: record.scriptBehavior, + behavior: record.behavior, + path, + }) + await runBehavior(resolved, record, request, response) + } + + const server = createServer((request, response) => { + /* v8 ignore start -- last-resort containment for Node response failures after validated test inputs */ + handle(request, response).catch((error: unknown) => { + const record = requests.at(-1) + if (record !== undefined) finishRecord(resolved, record, 'server_error') + if (response.headersSent) { + response.destroy(error instanceof Error ? error : new Error(String(error))) + return + } + response.writeHead(500, { 'content-type': 'application/json' }) + response.end(JSON.stringify({ error: { message: 'mock server handler failed', code: 'MOCK_HANDLER_FAILED' } })) + }) + /* v8 ignore stop */ + }) + + let closing: Promise | undefined + const close = (): Promise => (closing ??= new Promise((resolveClose) => { + server.close(() => { resolveClose() }) + server.closeAllConnections() + })) + + await new Promise((resolveListen, rejectListen) => { + server.once('error', rejectListen) + server.listen(resolved.port, resolved.host, () => { + server.off('error', rejectListen) + resolveListen() + }) + }) + + const address = server.address() as AddressInfo + return { + baseURL: `http://${resolved.host}:${address.port}`, + port: address.port, + randomSeed: resolved.randomSeed, + requests, + close, + } +} diff --git a/packages/support/llm-mock-server/src/invariant.ts b/packages/support/llm-mock-server/src/invariant.ts new file mode 100644 index 0000000000..b8fbc2dd40 --- /dev/null +++ b/packages/support/llm-mock-server/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-llm-mock-server`. + * @module @deepseek-ai/dsh-llm-mock-server/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-llm-mock-server' + +/** Cordis companion plugin name. */ +export const name = 'llm-mock-server-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this standalone test server owns no Cordis event stream or shared data; + * its wire behavior and lifecycle are exercised through direct HTTP and assembled-loop tests. + */ +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/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts new file mode 100644 index 0000000000..66a3868963 --- /dev/null +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' +import { + MOCK_LLM_CLI_USAGE, + parseMockLlmCliArgs, +} from '../src/cli.ts' + +describe('mock LLM server CLI parser', () => { + it('returns help without requiring a sequence', () => { + expect(parseMockLlmCliArgs(['--help'])).toEqual({ kind: 'help' }) + expect(MOCK_LLM_CLI_USAGE).toContain('--sequence') + }) + + it('parses every request and listener option', () => { + expect(parseMockLlmCliArgs([ + '--sequence', 'connection_refused,partial_disconnect,success', + '--host', 'localhost', + '--port', '9010', + '--api-key', 'mock-key', + '--listen-delay-ms', '100', + '--repeat-last', + '--success-text', 'done', + '--partial-text', 'half', + '--reasoning-text', 'think', + '--chunk-size', '2', + '--chunk-delay-ms', '3', + '--disconnect-delay-ms', '4', + '--retry-after-ms', '5000', + '--request-id', 'request-1', + '--tool-name', 'lookup', + '--tool-arguments', '{"id":1}', + ])).toEqual({ + kind: 'run', + config: { + startsUnavailable: true, + listenDelayMs: 100, + server: { + sequence: ['partial_disconnect', 'success'], + host: 'localhost', + port: 9010, + apiKey: 'mock-key', + repeatLast: true, + successText: 'done', + partialText: 'half', + reasoningText: 'think', + chunkSize: 2, + chunkDelayMs: 3, + disconnectDelayMs: 4, + retryAfterMs: 5000, + requestId: 'request-1', + toolName: 'lookup', + toolArguments: '{"id":1}', + }, + }, + }) + }) + + it('uses standalone defaults for an ordinary sequence', () => { + expect(parseMockLlmCliArgs(['--sequence', 'success'])).toEqual({ + kind: 'run', + config: { + startsUnavailable: false, + listenDelayMs: 0, + server: { + sequence: ['success'], + port: 8000, + repeatLast: false, + }, + }, + }) + }) + + it('uses the default unavailable interval', () => { + const result = parseMockLlmCliArgs(['--sequence', 'connection_refused,success', '--port', '8001']) + expect(result).toMatchObject({ + kind: 'run', + config: { startsUnavailable: true, listenDelayMs: 750 }, + }) + }) + + it('parses a reproducible weighted random profile', () => { + expect(parseMockLlmCliArgs([ + '--sequence', 'random', + '--repeat-last', + '--seed', '42', + '--random-weights', 'success=8,partial_disconnect=2', + ])).toEqual({ + kind: 'run', + config: { + startsUnavailable: false, + listenDelayMs: 0, + server: { + sequence: ['random'], + port: 8000, + repeatLast: true, + randomSeed: 42, + randomWeights: { success: 8, partial_disconnect: 2 }, + }, + }, + }) + }) + + it.each([ + [[], /--sequence is required/], + [['--wat'], /requires a value/], + [['--wat', 'x'], /unknown option/], + [['--port', 'NaN', '--sequence', 'success'], /finite number/], + [['--sequence', 'success,'], /non-empty/], + [['--sequence', 'success,connection_refused'], /only as the first/], + [['--sequence', 'connection_refused'], /must be followed/], + [['--sequence', 'unknown'], /unknown behavior/], + [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], + [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], + [['--sequence', 'success', '--seed', '1'], /require random/], + [['--sequence', 'random', '--random-weights', 'success'], /expects behavior=weight/], + [['--sequence', 'random', '--random-weights', 'random=1'], /concrete behavior/], + [['--sequence', 'random', '--random-weights', 'success=1,success=2'], /duplicate/], + [['--sequence', 'random', '--random-weights', 'success=nope'], /finite number/], + ])('rejects invalid argv %#', (argv, expected) => { + expect(() => parseMockLlmCliArgs(argv)).toThrow(expected) + }) +}) diff --git a/packages/support/llm-mock-server/tests/invariant.spec.ts b/packages/support/llm-mock-server/tests/invariant.spec.ts new file mode 100644 index 0000000000..f45320d989 --- /dev/null +++ b/packages/support/llm-mock-server/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as MockServerInvariant from '../src/invariant.ts' + +describe('mock LLM server invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService) + const fiber = await ctx.plugin(MockServerInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-llm-mock-server', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts new file mode 100644 index 0000000000..b84931bc9f --- /dev/null +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -0,0 +1,312 @@ +import { afterEach, describe, expect, it } from 'vitest' +import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' +import { startMockLlmServer } from '../src/index.ts' + +const running: MockLlmServer[] = [] + +afterEach(async () => { + await Promise.all(running.splice(0).map(server => server.close())) +}) + +async function start( + sequence: readonly MockLlmBehavior[], + options: Omit[0], 'sequence'> = {}, +): Promise { + const server = await startMockLlmServer({ sequence, ...options }) + running.push(server) + return server +} + +function chat( + server: MockLlmServer, + options: { path?: string; key?: string; body?: string; signal?: AbortSignal } = {}, +): Promise { + return fetch(`${server.baseURL}${options.path ?? '/v1/chat/completions'}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...options.key === undefined ? {} : { authorization: `Bearer ${options.key}` }, + }, + body: options.body ?? JSON.stringify({ model: 'mock', messages: [], stream: true }), + ...options.signal === undefined ? {} : { signal: options.signal }, + }) +} + +describe('mock LLM server wire behaviors', () => { + it('streams a complete text response and captures the request', async () => { + const events: MockLlmServerEvent[] = [] + const server = await start(['success'], { + apiKey: 'mock-key', + successText: 'recovered', + chunkSize: 3, + onEvent: (event) => { events.push(event) }, + }) + + const response = await chat(server, { key: 'mock-key' }) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + expect(body).toContain('"content":"rec"') + expect(body).toContain('"content":"ove"') + expect(body).toContain('"content":"red"') + expect(body).toContain('"finish_reason":"stop"') + expect(body).toContain('data: [DONE]') + expect(server.requests).toEqual([expect.objectContaining({ + attempt: 1, + behavior: 'success', + path: '/v1/chat/completions', + body: { model: 'mock', messages: [], stream: true }, + chunksSent: 5, + outcome: 'completed', + })]) + expect(events).toEqual([ + { + type: 'request', + attempt: 1, + scriptBehavior: 'success', + behavior: 'success', + path: '/v1/chat/completions', + }, + { + type: 'result', + attempt: 1, + scriptBehavior: 'success', + behavior: 'success', + outcome: 'completed', + chunksSent: 5, + }, + ]) + }) + + it('supports root paths and intentionally ignores telemetry observer failures', async () => { + const server = await start(['empty'], { + onEvent() { + throw new Error('observer failed') + }, + }) + const response = await chat(server, { path: '/chat/completions' }) + + expect(response.status).toBe(200) + expect(await response.text()).toContain('data: [DONE]') + expect(server.requests[0]).toMatchObject({ path: '/chat/completions', outcome: 'completed' }) + }) + + it.each([ + ['empty_body', 0, ''] as const, + ['stream_eof', 1, '"role":"assistant"'] as const, + ['partial_eof', 1, 'discarded partial response'] as const, + ['malformed_json', 2, 'data: {not-json'] as const, + ['malformed_event', 2, '"choices":[null]'] as const, + ])('serves %s without inventing a terminal completion', async (behavior, chunks, marker) => { + const server = await start([behavior], { chunkSize: 100 }) + const response = await chat(server) + const body = await response.text() + + expect(response.status).toBe(200) + expect(body).toContain(marker) + if (behavior !== 'malformed_json' && behavior !== 'malformed_event') { + expect(body).not.toContain('[DONE]') + } + expect(server.requests[0]).toMatchObject({ behavior, chunksSent: chunks, outcome: 'completed' }) + }) + + it.each([ + ['connection_reset', false] as const, + ['stream_disconnect', true] as const, + ['partial_disconnect', true] as const, + ])('forces the %s transport boundary', async (behavior, receivesHeaders) => { + const server = await start([behavior], { disconnectDelayMs: 20, partialText: 'half' }) + + let headersReceived = false + await expect((async () => { + const response = await chat(server) + headersReceived = true + await response.text() + })()).rejects.toThrow() + + expect(headersReceived).toBe(receivesHeaders) + expect(server.requests[0]).toMatchObject({ + behavior, + chunksSent: behavior === 'partial_disconnect' ? 1 : 0, + outcome: 'reset', + }) + }) + + it('holds a stalled stream until the client aborts and server close remains idempotent', async () => { + const server = await start(['stall']) + const controller = new AbortController() + const response = await chat(server, { signal: controller.signal }) + + expect(response.status).toBe(200) + expect(server.requests[0]).toMatchObject({ behavior: 'stall', outcome: 'stalled' }) + controller.abort() + await expect(response.text()).rejects.toThrow() + await server.close() + await server.close() + }) + + it.each([ + ['slow_success', 100] as const, + ['stream_disconnect', 100] as const, + ['partial_disconnect', 100] as const, + ])('records a client that closes during %s', async (behavior, delayMs) => { + const server = await start([behavior], { + chunkDelayMs: delayMs, + disconnectDelayMs: delayMs, + chunkSize: 1, + }) + const controller = new AbortController() + const response = await chat(server, { signal: controller.signal }) + controller.abort() + await expect(response.text()).rejects.toThrow() + await new Promise((resolve) => { setTimeout(resolve, 5) }) + + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + }) + + it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => { + const server = await start([ + 'reasoning_success', + 'tool_call_success', + 'max_tokens', + 'slow_success', + 'wrong_content_type', + ], { + successText: 'answer', + reasoningText: 'think', + toolName: 'lookup', + toolArguments: '{"id":7}', + chunkDelayMs: 1, + chunkSize: 2, + }) + + const bodies: string[] = [] + const contentTypes: Array = [] + for (let index = 0; index < 5; index += 1) { + const response = await chat(server) + contentTypes.push(response.headers.get('content-type')) + bodies.push(await response.text()) + } + + expect(bodies[0]).toContain('"reasoning_content":"th"') + expect(bodies[1]).toContain('"name":"lookup"') + expect(bodies[1]).toContain('"arguments":"{\\"id"') + expect(bodies[1]).toContain('"finish_reason":"tool_calls"') + expect(bodies[2]).toContain('"finish_reason":"length"') + expect(bodies[3]).toContain('"finish_reason":"stop"') + expect(contentTypes[4]).toBe('application/json') + expect(server.requests).toHaveLength(5) + expect(server.requests.every(record => record.outcome === 'completed')).toBe(true) + }) + + it.each([ + ['rate_limit', 429, 'mock rate limit'] as const, + ['server_error', 500, 'mock server error'] as const, + ['service_unavailable', 503, 'mock service unavailable'] as const, + ['auth_error', 401, 'mock authentication failed'] as const, + ['invalid_request', 400, 'mock invalid request'] as const, + ['context_overflow', 400, 'context_length_exceeded'] as const, + ['quota_exceeded', 429, 'insufficient_quota'] as const, + ])('serves %s as a structured HTTP error', async (behavior, status, marker) => { + const server = await start([behavior], { retryAfterMs: 1_001, requestId: 'mock-request-1' }) + const response = await chat(server) + const body = await response.text() + + expect(response.status).toBe(status) + expect(body).toContain(marker) + expect(response.headers.get('x-request-id')).toBe('mock-request-1') + if (behavior === 'rate_limit') expect(response.headers.get('retry-after')).toBe('2') + else expect(response.headers.get('retry-after')).toBeNull() + expect(server.requests[0]?.outcome).toBe('completed') + }) + + it('fails loud on script exhaustion and can explicitly repeat the final behavior', async () => { + const exhausted = await start(['success'], { successText: 'once' }) + await (await chat(exhausted)).text() + const exhaustedResponse = await chat(exhausted) + expect(exhaustedResponse.status).toBe(500) + expect(await exhaustedResponse.text()).toContain('mock script exhausted') + expect(exhausted.requests.map(record => record.behavior)).toEqual(['success', 'script_exhausted']) + + const repeating = await start(['empty'], { repeatLast: true }) + await (await chat(repeating)).text() + await (await chat(repeating)).text() + expect(repeating.requests.map(record => record.behavior)).toEqual(['empty', 'empty']) + }) + + it('selects weighted random behaviors reproducibly and reports the concrete choice', async () => { + const options = { + sequence: ['random'] as const, + repeatLast: true, + randomSeed: 42, + randomWeights: { success: 1, empty: 1 }, + successText: 'random success', + } + const first = await startMockLlmServer(options) + const second = await startMockLlmServer(options) + running.push(first, second) + + for (let attempt = 0; attempt < 12; attempt += 1) { + await (await chat(first)).text() + await (await chat(second)).text() + } + + const firstChoices = first.requests.map(record => record.behavior) + expect(first.randomSeed).toBe(42) + expect(second.randomSeed).toBe(42) + expect(firstChoices).toEqual(second.requests.map(record => record.behavior)) + expect(new Set(firstChoices)).toEqual(new Set(['success', 'empty'])) + expect(first.requests.every(record => record.scriptBehavior === 'random')).toBe(true) + }) + + it('rejects invalid method, route, bearer token, and JSON without consuming the script', async () => { + const server = await start(['success'], { apiKey: 'expected' }) + const method = await fetch(`${server.baseURL}/v1/chat/completions`) + const route = await fetch(`${server.baseURL}/v1/other`, { method: 'POST', body: '{}' }) + const auth = await chat(server, { key: 'wrong' }) + const json = await chat(server, { key: 'expected', body: '{' }) + + expect(method.status).toBe(405) + expect(method.headers.get('allow')).toBe('POST') + expect(route.status).toBe(404) + expect(auth.status).toBe(401) + expect(json.status).toBe(400) + expect(server.requests).toHaveLength(0) + + const emptyRequest = await fetch(`${server.baseURL}/v1/chat/completions`, { + method: 'POST', + headers: { authorization: 'Bearer expected' }, + }) + expect(emptyRequest.status).toBe(200) + expect(server.requests[0]?.behavior).toBe('success') + expect(server.requests[0]?.body).toBeUndefined() + }) +}) + +describe('mock LLM server option validation', () => { + it.each([ + [{ sequence: [] }, /sequence/], + [{ sequence: ['success'], host: '' }, /host/], + [{ sequence: ['success'], port: -1 }, /port/], + [{ sequence: ['success'], port: 65_536 }, /port/], + [{ sequence: ['success'], apiKey: '' }, /apiKey/], + [{ sequence: ['success'], successText: '' }, /successText/], + [{ sequence: ['success'], partialText: '' }, /partialText/], + [{ sequence: ['success'], reasoningText: '' }, /reasoningText/], + [{ sequence: ['success'], chunkSize: 0 }, /chunkSize/], + [{ sequence: ['success'], chunkDelayMs: -1 }, /chunkDelayMs/], + [{ sequence: ['success'], disconnectDelayMs: Number.POSITIVE_INFINITY }, /disconnectDelayMs/], + [{ sequence: ['success'], retryAfterMs: 0 }, /retryAfterMs/], + [{ sequence: ['success'], requestId: '' }, /requestId/], + [{ sequence: ['success'], toolName: '' }, /toolName/], + [{ sequence: ['success'], toolArguments: '{' }, /toolArguments/], + [{ sequence: ['random'], randomSeed: -1 }, /randomSeed/], + [{ sequence: ['random'], randomWeights: { random: 1 } }, /unknown concrete behavior/], + [{ sequence: ['random'], randomWeights: { success: -1 } }, /non-negative/], + [{ sequence: ['random'], randomWeights: { success: 0 } }, /positive weight/], + ] as const)('rejects invalid options %#', async (options, expected) => { + await expect(startMockLlmServer(options as Parameters[0])) + .rejects.toThrow(expected) + }) +}) diff --git a/packages/support/llm-mock-server/tsconfig.json b/packages/support/llm-mock-server/tsconfig.json new file mode 100644 index 0000000000..d970a00263 --- /dev/null +++ b/packages/support/llm-mock-server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/support/llm-mock-server/tsdown.config.ts b/packages/support/llm-mock-server/tsdown.config.ts new file mode 100644 index 0000000000..3dcb19efab --- /dev/null +++ b/packages/support/llm-mock-server/tsdown.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'tsdown' + +/** Builds each public entry as a self-contained file admitted by the package whitelist. */ +export default defineConfig([ + { + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/invariant.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, + { + entry: ['lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..5986b8f978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2290,12 +2290,21 @@ importers: '@deepseek-ai/dsh-agent-loop': specifier: workspace:^ version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../llm-deepseek + '@deepseek-ai/dsh-llm-mock-server': + specifier: workspace:^ + version: link:../../support/llm-mock-server '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3455,6 +3464,15 @@ importers: 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/support/llm-mock-server: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../invariants + 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/support/llm-replay: devDependencies: '@deepseek-ai/dsh-invariants': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index d5fa282a24..b5a39fa5f9 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -90,6 +90,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, + 'packages/support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, diff --git a/tsconfig.host.json b/tsconfig.host.json index a13bcf35e3..d074f3a658 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -115,6 +115,7 @@ { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, + { "path": "./packages/support/llm-mock-server" }, { "path": "./packages/subagent/subagent" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, From 870fb1cafa32feeac857b1ca62028df79b843a25 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 12:43:59 +0800 Subject: [PATCH 31/70] 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 32/70] 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 33/70] 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 7cdf36dfd057dbfc51238069390b6972eb88953c Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:18:16 +0800 Subject: [PATCH 34/70] docs: require appropriate PR labels --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 8007f32b90..98b23b88ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,6 +110,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's ACP render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd33a039a2d4c58afc50f870bf7e3e37b9d9c2e9 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:25:44 +0800 Subject: [PATCH 35/70] docs: fit PR label rule within budget --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 95364b5c56..f2d25c71d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including labels that trigger optional CI workflows. +- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From dc5045dc0ae32b0ec15d945b9978109efd75e5b7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 14:30:52 +0800 Subject: [PATCH 36/70] docs: keep PR label guidance general --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index f2d25c71d6..8c4cc143f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes, including optional CI-trigger labels. +- **Label PRs appropriately.** Apply labels required by each PR's changes. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 91d86f9b210854d3a95ca6c33834bb1154695361 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 15:03:17 +0800 Subject: [PATCH 37/70] 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 38/70] 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 39/70] =?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 40/70] 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 41/70] 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 3feb05fef83a956e73b0a28057c8cd13bebf3dfd Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 25 Jul 2026 16:43:07 +0800 Subject: [PATCH 42/70] docs(agent-notes): consolidate superseded decisions --- .agents/notes/README.i18n.yaml | 4 +- .agents/notes/README.md | 4 +- .agents/notes/README.zh.md | 4 +- .agents/notes/implemented/AGENTS.md | 2 +- .../2026-06-11-custom-schema-dsl.i18n.yaml | 6 - .../2026-06-11-custom-schema-dsl.md | 23 --- .../2026-06-11-custom-schema-dsl.zh.md | 23 --- .../2026-06-20-package-hierarchy.i18n.yaml | 4 +- .../2026-06-20-package-hierarchy.md | 2 +- .../2026-06-20-package-hierarchy.zh.md | 2 +- ...6-07-02-tool-render-intent-union.i18n.yaml | 4 +- .../2026-07-02-tool-render-intent-union.md | 3 + .../2026-07-02-tool-render-intent-union.zh.md | 3 + .../2026-07-05-windows-fs-permissions.md | 31 --- ...20-unified-json-value-schema-dsl.i18n.yaml | 4 +- ...026-07-20-unified-json-value-schema-dsl.md | 2 + ...-07-20-unified-json-value-schema-dsl.zh.md | 2 + ...s-atomic-write-dacl-preservation.i18n.yaml | 4 +- ...-windows-atomic-write-dacl-preservation.md | 12 +- ...ndows-atomic-write-dacl-preservation.zh.md | 12 +- ...-06-14-acp-agent-client-protocol.i18n.yaml | 6 - .../2026-06-14-acp-agent-client-protocol.md | 61 ------ ...2026-06-14-acp-agent-client-protocol.zh.md | 61 ------ ...-acp-terminal-and-tool-rendering.i18n.yaml | 6 - ...6-06-18-acp-terminal-and-tool-rendering.md | 50 ----- ...6-18-acp-terminal-and-tool-rendering.zh.md | 50 ----- .../2026-07-06-approval-seam.i18n.yaml | 4 +- .../feature/2026-07-06-approval-seam.md | 4 +- .../feature/2026-07-06-approval-seam.zh.md | 4 +- .../feature/2026-07-07-plan-mode.md | 194 ------------------ .../2026-07-14-time-context-plugin.i18n.yaml | 6 - .../feature/2026-07-14-time-context-plugin.md | 59 ------ .../2026-07-14-time-context-plugin.zh.md | 59 ------ ...16-durable-per-step-time-context.i18n.yaml | 4 +- ...026-07-16-durable-per-step-time-context.md | 17 +- ...-07-16-durable-per-step-time-context.zh.md | 17 +- .../2026-07-20-tui-startup-slogans.i18n.yaml | 6 - .../feature/2026-07-20-tui-startup-slogans.md | 39 ---- .../2026-07-20-tui-startup-slogans.zh.md | 39 ---- .../2026-07-21-tui-auto-pane-title.i18n.yaml | 6 - .../feature/2026-07-21-tui-auto-pane-title.md | 41 ---- .../2026-07-21-tui-auto-pane-title.zh.md | 41 ---- ...-07-21-tui-auto-title-default-on.i18n.yaml | 6 - .../2026-07-21-tui-auto-title-default-on.md | 32 --- ...2026-07-21-tui-auto-title-default-on.zh.md | 32 --- .../2026-07-21-tui-banner-sweep.i18n.yaml | 6 - .../feature/2026-07-21-tui-banner-sweep.md | 35 ---- .../feature/2026-07-21-tui-banner-sweep.zh.md | 35 ---- ...2026-07-21-tui-borderless-banner.i18n.yaml | 4 +- .../2026-07-21-tui-borderless-banner.md | 25 ++- .../2026-07-21-tui-borderless-banner.zh.md | 25 ++- .../2026-07-21-tui-no-banner.i18n.yaml | 6 - .../feature/2026-07-21-tui-no-banner.md | 39 ---- .../feature/2026-07-21-tui-no-banner.zh.md | 39 ---- ...26-07-21-tui-verbose-status-line.i18n.yaml | 4 +- .../2026-07-21-tui-verbose-status-line.md | 2 +- .../2026-07-21-tui-verbose-status-line.zh.md | 2 +- ...6-07-06-parallel-github-ci-gates.i18n.yaml | 6 - .../2026-07-06-parallel-github-ci-gates.md | 50 ----- .../2026-07-06-parallel-github-ci-gates.zh.md | 50 ----- ...nt-notes-for-non-trivial-changes.i18n.yaml | 4 +- ...ire-agent-notes-for-non-trivial-changes.md | 10 + ...-agent-notes-for-non-trivial-changes.zh.md | 10 + ...-doc-sync-through-gate-scheduler.i18n.yaml | 4 +- ...6-07-21-doc-sync-through-gate-scheduler.md | 2 +- ...7-21-doc-sync-through-gate-scheduler.zh.md | 2 +- ...ence-based-larger-hosted-runners.i18n.yaml | 4 +- ...22-evidence-based-larger-hosted-runners.md | 8 + ...evidence-based-larger-hosted-runners.zh.md | 8 + .../2026-07-04-fold-stdio-ui-helper.i18n.yaml | 6 - .../2026-07-04-fold-stdio-ui-helper.md | 30 --- .../2026-07-04-fold-stdio-ui-helper.zh.md | 30 --- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 4 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 10 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 10 +- ...07-20-retire-readline-front-door.i18n.yaml | 6 - .../2026-07-20-retire-readline-front-door.md | 46 ----- ...026-07-20-retire-readline-front-door.zh.md | 46 ----- ...lan-specific-collaboration-state.i18n.yaml | 4 +- ...07-22-plan-specific-collaboration-state.md | 29 ++- ...22-plan-specific-collaboration-state.zh.md | 29 ++- ...itles-from-session-title-service.i18n.yaml | 4 +- ...2-tui-titles-from-session-title-service.md | 16 +- ...ui-titles-from-session-title-service.zh.md | 16 +- ...-23-acp-automation-only-protocol.i18n.yaml | 4 +- ...2026-07-23-acp-automation-only-protocol.md | 8 +- ...6-07-23-acp-automation-only-protocol.zh.md | 8 +- ...026-06-20-drop-acp-terminal-meta.i18n.yaml | 4 +- .../2026-06-20-drop-acp-terminal-meta.md | 4 +- .../2026-06-20-drop-acp-terminal-meta.zh.md | 4 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/persistence-catalog.md | 2 +- packages/plan/README.md | 2 +- packages/plan/plan-mode/README.md | 2 +- packages/plan/plan-mode/src/index.ts | 3 +- scripts/translation-pairing.manifest.json | 5 - 97 files changed, 279 insertions(+), 1432 deletions(-) delete mode 100644 .agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml delete mode 100644 .agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md delete mode 100644 .agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md delete mode 100644 .agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md delete mode 100644 .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md delete mode 100644 .agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md delete mode 100644 .agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-07-plan-mode.md delete mode 100644 .agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-14-time-context-plugin.md delete mode 100644 .agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md delete mode 100644 .agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.md delete mode 100644 .agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md delete mode 100644 .agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md delete mode 100644 .agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md delete mode 100644 .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index fa9c0d9a21..3853edbc6b 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.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 -README.md: 4db9f16956b9c569cf5f9b53f04cb650f6058668 -README.zh.md: 60ec5421e7f271460daebc966aa6548f6ef8a511 +README.md: a0f01a68ccd838ec405392679d20e7316fba78ef +README.zh.md: 2df46224569daed0ac3a469ce0799018301df195 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index 4db9f16956..a0f01a68cc 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -37,7 +37,9 @@ The `architecture` / `process` line: **architecture** is about the source we shi Every non-trivial change MUST add or update at least one Agent Note in the same PR. A change is non-trivial when it alters behavior, architecture, a cross-file or cross-package contract, process or tooling, testing strategy, an on-disk, wire, or configuration format, or another decision a maintainer may reasonably revisit. A proposal for substantial future work starts in `proposed/`; a decision already made starts in `implemented/`. Pick the class folder that matches the decision (see [Classification](#classification)). -Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one and cross-link. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). +Updating the Agent Note that already owns the decision satisfies the rule; do not create a duplicate. Only a purely mechanical or local edit with no behavioral, contractual, structural, process, or rationale change is exempt. An Agent Note is never edited into a *different decision*: supersede it with a new one, and keep both notes cross-linked unless the old note is later fully consolidated under the rule below. Editing an `implemented/` Agent Note to track where its existing decision lives is required, not forbidden; see [implemented/AGENTS.md](implemented/AGENTS.md). + +An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete any Chinese counterpart, consistency record, and `required` entry in [the translation-pairing manifest](../../scripts/translation-pairing.manifest.json) in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. ## The file format diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 60ec5421e7..2df4622456 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -39,7 +39,9 @@ 每个非平凡变更都必须在同一 PR(Pull Request)中新增或更新至少一份 Agent Note。如果变更修改了行为、架构、跨文件或跨包契约、流程或工具、测试策略、磁盘、协议或配置格式,或者其他维护者可能合理重新审视的决策,就属于非平凡变更。对未来重大工作的提案从 `proposed/` 开始;已经做出的决策从 `implemented/` 开始。选择与决策匹配的类别文件夹(见[分类](#classification))。 -更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧的,并互相链接。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 +更新已经拥有该决策的 Agent Note 即可满足规则;不要创建重复记录。只有不涉及行为、契约、结构、流程或理由变化的纯机械性或局部编辑才可豁免。Agent Note 永远不会被编辑为一个*不同的决策*:用新 Agent Note 取代旧记录,并让两个记录保持互相链接,除非后续依据下方规则完全合并旧记录。编辑 `implemented/` Agent Note 以跟踪其现有决策的所在位置是必需的,而非禁止的;见 [implemented/AGENTS.md](implemented/AGENTS.md)。 + +被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件、一致性记录,以及[翻译配对 manifest(元数据清单)](../../scripts/translation-pairing.manifest.json)中对应的 `required` 条目。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 diff --git a/.agents/notes/implemented/AGENTS.md b/.agents/notes/implemented/AGENTS.md index 5fb3fde8e2..c34e1a49b8 100644 --- a/.agents/notes/implemented/AGENTS.md +++ b/.agents/notes/implemented/AGENTS.md @@ -8,4 +8,4 @@ Keep paths, symbols, defaults, and mechanisms current in the same change that al ### This is not a license to rewrite the *decision* -Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; see the [Agent Note contract](../README.md). +Update factual realization in place. A reversal of the decision or its rationale requires a new Agent Note and cross-link; a fully superseded old note may be deleted only through the consolidation rule in the [Agent Note contract](../README.md). diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml deleted file mode 100644 index 41265a5b0f..0000000000 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-06-11-custom-schema-dsl.md: 947d53555df078bfa9f3dac48eab4b8c0074007c -2026-06-11-custom-schema-dsl.zh.md: 26ebfe2fb15a6c034e809b3f51187342fa500193 diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md deleted file mode 100644 index 947d53555d..0000000000 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: Custom typed tool-schema DSL instead of schemastery - -Status: implemented - -English | [中文](2026-06-11-custom-schema-dsl.zh.md) - -## Problem - -Tool parameters must reach the model as standard JSON Schema while giving tool authors typed `execute(args)` without casts. Schemastery already serves plugin config, but the tool-author API needs per-property `required: true` booleans rather than JSON Schema's separate `required` array. - -## Decision - -This decision is superseded by the [unified JSON-value schema DSL](2026-07-20-unified-json-value-schema-dsl.md), which retains the small authoring surface while making parameters and typed values share one vocabulary. `ParameterSchemaSpec` keeps per-property `required: true`; `InferArgs` maps required keys to non-optional properties; `parameterSchemaSpecToJsonSchema()` compiles the implicit open object root; and `defineTool()` ties inference, compilation, and validation together. Raw JSON-Schema `ToolDefinition`s remain accepted by `ToolRegistry.register()` for MCP and other external tools. - -## Alternatives considered - -**Schemastery** (already vendored, used for plugin Config) was evaluated and rejected for this use: it targets validation / transformation against StandardSchema, not JSON Schema *generation*, so it would add indirection without producing the wire format cleanly. - -## Consequences - -- First-party tool authors get zero-cast typed args; the type gymnastics cost stays inside the core package (sanctioned by the AGENTS.md type-safety policy). -- The owning unified note defines the current nodes, literal constraints, unions, JSON-value boundary, and object-openness rules. -- The `InferArgs` mapping is regression-tested at the type level after an early optionality bug. diff --git a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md deleted file mode 100644 index 26ebfe2fb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.zh.md +++ /dev/null @@ -1,23 +0,0 @@ -# Agent Note: 使用自定义类型化工具 schema DSL 替代 schemastery - -Status: implemented - -[English](2026-06-11-custom-schema-dsl.md) | 中文 - -## 问题 - -工具参数必须以标准 JSON Schema 形式到达模型,同时让工具作者在 `execute(args)` 中获得类型化的参数而无需类型断言。Schemastery 已用于插件配置,但工具作者 API 需要逐属性的 `required: true` 布尔值,而非 JSON Schema 的独立 `required` 数组。 - -## 决策 - -该决策已由[统一 JSON 值 schema DSL](2026-07-20-unified-json-value-schema-dsl.md)取代;新设计保留小型编写接口,同时让参数与类型化值共享一套词汇。`ParameterSchemaSpec` 保留逐属性的 `required: true`;`InferArgs` 将必需键映射为非可选属性;`parameterSchemaSpecToJsonSchema()` 编译隐式开放的对象根;`defineTool()` 则将类型推导、编译与校验串联起来。原始 JSON Schema 的 `ToolDefinition` 仍是 `ToolRegistry.register()` 接受的输入,供 MCP 和其他外部工具使用。 - -## 曾考虑的替代方案 - -**Schemastery**(已作为 vendor 引入,用于插件 Config)经评估后被否决:它面向的是基于 StandardSchema 的校验/转换,而非 JSON Schema *生成*,因此会增加间接层却无法干净地产出协议格式(wire format)。 - -## 后果 - -- 第一方工具作者获得零类型断言的类型化参数;类型体操的成本留在核心包内部(符合 AGENTS.md 的类型安全策略)。 -- 当前节点、字面量约束、联合类型、JSON 值边界与对象开放性规则均由上述统一说明定义。 -- `InferArgs` 映射在类型层面有回归测试,源于早期一个可选性 bug。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml index 7db4f97aa4..03a271a9c3 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.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-06-20-package-hierarchy.md: 7cd07ff90225872f2a17b9a678e52fcee416b09a -2026-06-20-package-hierarchy.zh.md: 9ef89bd56144b39bb3240a22a2bb1e9216e24115 +2026-06-20-package-hierarchy.md: 4e05e3487483ab8d710959c1888ec1f5c3b37432 +2026-06-20-package-hierarchy.zh.md: f57704ad082c4961aa48056af1b4b279d2f2c055 diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md index 7cd07ff902..4e05e34874 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-06-20-package-hierarchy.zh.md) -The later [fold-stdio-helper](../simplification/2026-07-04-fold-stdio-ui-helper.md) decision superseded the original `support/ui-stdio` placement, and the [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) subsequently removed that surface entirely. The [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here. +The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) deletes the original `support/ui-stdio` surface instead of relocating it, and the [automation-only ACP decision](../simplification/2026-07-23-acp-automation-only-protocol.md) places ACP under `packages/acp/acp` instead of the human-UI group. The uniform depth-two hierarchy remains the decision owned here. ## Problem diff --git a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md index 9ef89bd561..f57704ad08 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-package-hierarchy.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-06-20-package-hierarchy.md) | 中文 -后续的[折叠 stdio helper](../simplification/2026-07-04-fold-stdio-ui-helper.md)决策取代了最初的 `support/ui-stdio` 放置方式,[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)随后又彻底移除了该接口。[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。 +[冗余 agent 移除](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)直接删除最初的 `support/ui-stdio` 接口,而不是将其迁移;[仅面向自动化的 ACP 决策](../simplification/2026-07-23-acp-automation-only-protocol.md)把 ACP 放在 `packages/acp/acp` 下,而不是面向人类的 UI 组。这里拥有的决策仍是统一的二层目录深度。 ## 问题 diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml index 9871c7a528..d2ac37af62 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.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-02-tool-render-intent-union.md: 6cfd8921decbe16343f963574edd52173c2f8698 -2026-07-02-tool-render-intent-union.zh.md: d0414c5f15995192df898e968d054933f82d2ab4 +2026-07-02-tool-render-intent-union.md: 84423e9000526848a111591c1bb2ab92067bbe50 +2026-07-02-tool-render-intent-union.zh.md: 43873c622fc8483b4a7033d17b4b4fab1342a56b diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md index 6cfd8921de..84423e9000 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md @@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string `TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte. +The terminal intent is display-only. The harness still executes the command through its bash service, preserving sandboxing, environment scrubbing, task ownership, and per-session cwd; a UI projects the completed call and never becomes a second execution backend. + ### Purity preserved `presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`. @@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## Alternatives considered - **Delete tool-owned presentation entirely** — [the rejected collapse proposal](../../rejected/simplification/2026-06-20-generic-tool-rendering.md); its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met. +- **Let a UI execute terminal intents** — rejected because it would bypass the harness's bash policy and ownership contracts and fork command execution across backends. A terminal card describes harness-owned execution; it never authorizes client-side execution. - **A merge-extensible union** (the `ContentBlockMap` pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's `assertNever` switch. - **Keeping the optional-field bag** — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all. diff --git a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md index d0414c5f15..43873c622f 100644 --- a/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.zh.md @@ -54,6 +54,8 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string `TerminalResultView` 只携带 `output`/`exitCode`/`signal`。不具备终端能力的 UI 需要一个围栏 ` ```console ` 文本回退;该推导移至 **bridge**(在无能力路径上将 `output` 包裹在围栏代码块中),而非由工具双重编码。这使 bash 工具的结果保持单一结构化形状,并逐字节保留既有的能力门控行为。 +terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命令,从而保留沙箱、环境清理、任务归属和每会话 cwd;UI 只呈现已完成的调用,绝不会成为第二个执行后端。 + ### 纯函数性保持不变 `presentCall`/`presentResult` 仍然是 `args`(`presentResult` 还有 result)的纯函数——它们在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性。每个 view 仅从 args 推导:write 的 diff 是新文件风格(`oldText:null`),因为工具在调用时没有旧内容;edit 的 diff 是 `old_string`→`new_string`。 @@ -61,6 +63,7 @@ interface TerminalResultView { card: 'terminal'; title?: string; output?: string ## 曾考虑的替代方案 - **完全删除工具自有的展示**:即[被否决的 collapse 提案](../../rejected/simplification/2026-06-20-generic-tool-rendering.md);其自身的结论正是推迟到两个真实工具和两个真实消费方存在后再做此联合类型,该条件现已满足。 +- **让 UI 执行 terminal 意图**:否决。这样会绕过 harness 的 bash 策略与归属契约,并把命令执行分裂到不同后端。terminal 卡片描述的是 harness 拥有的执行,绝不授权客户端侧执行。 - **可合并扩展的联合类型**(`ContentBlockMap` 模式):否决。新的渲染意图无论如何需要新的 bridge 代码来渲染,因此一个被 bridge 静默丢弃的插件添加变体,比封闭联合类型在 bridge 的 `assertNever` switch 处引发的编译错误更糟糕。 - **保留可选字段集合**:即「问题」一节所剖析的现状:无效状态可表达、字段交互无文档、且完全无法请求 diff 卡片。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md b/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md deleted file mode 100644 index 932b6ddbf4..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-fs-permissions.md +++ /dev/null @@ -1,31 +0,0 @@ -# Agent Note: Windows write-permission semantics — inherited DACLs, not mode bits - -Status: implemented - -The replacement-file decision in this record is superseded by [Windows DACL preservation](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). - -## Problem - -`writeFileAtomic` in `@deepseek-ai/dsh-fs-local` protects write-in-progress content with POSIX mode bits: the staging directory is created `0o700`, the temp file is opened `0o600`, and new files default to `0o600`. On POSIX this keeps temporary content owner-only regardless of the parent directory's permissions. - -Windows has no working equivalent behind the same API. Node's `chmod` there drives only the read-only attribute (every mode this package passes carries owner-write, so the calls are benign no-ops), and `stat().mode` reports synthetic `0o666`/`0o444` bits. The real security state is the file's DACL: a newly created file or directory inherits from its parent, while replacement needs the explicit handling owned by the superseding Agent Note. - -## Decision - -New Windows files use directory inheritance rather than synthetic mode bits: the staging directory is created inside the target's parent directory (`dirname(absolutePath)`), so it and the temp file inherit the destination directory's DACL. Replacement files follow the stricter [DACL preservation contract](../bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md). - -Tests assert mode bits on POSIX only. Native Windows coverage pins the package-owned replacement behavior; new-file inheritance remains an operating-system contract rather than a machine-specific ACL allowlist. - -## Alternatives considered - -**Explicit owner-only DACLs for new files.** Rejected because they would break inheritance and surprise users whose project directories are deliberately shared. Replacement writes copy the target's existing DACL rather than inventing an owner-only policy. - -**Test-side ACL verification.** A `Get-Acl` SID allowlist or `icacls` would verify Windows inheritance and the machine's `%TEMP%` ACL rather than package behavior; `icacls` also localizes well-known account names, making parsing locale-fragile. - -**Skip `chmod` on Windows.** Platform-guarding benign no-op calls adds branches without changing behavior. - -## Consequences - -POSIX keeps owner-only temp content regardless of the parent directory. A new Windows target inside a broadly accessible directory inherits that accessibility by design; a replacement retains the target's narrower DACL when one exists. - -Mode preservation across a replace degenerates to a no-op on Windows: a writable file probes as `0o666`, and replaying that through `chmod` leaves the read-only attribute clear. A read-only target cannot be replaced there because publication fails before the synthetic mode would matter. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml index 16852c1004..19a6b628c3 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.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-unified-json-value-schema-dsl.md: 09945c413ffe5924c74076648cdf3da60c3e18c9 -2026-07-20-unified-json-value-schema-dsl.zh.md: 00a7a199613ea857a7815f1c7794781f143a3896 +2026-07-20-unified-json-value-schema-dsl.md: 5de3523eab15a91ea32dc09e2e239146fadea6f1 +2026-07-20-unified-json-value-schema-dsl.zh.md: 321136c31a6aa6c0268150fcde2d97dcdbb0ac58 diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md index 09945c413f..5de3523eab 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md @@ -21,6 +21,7 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent ## Alternatives considered - **Keep separate parameter and structured-output schema systems:** rejected because every added output construct would require parallel inference, compilation, validation, and code-generation changes with no useful ownership boundary. +- **Use Schemastery for tool parameters:** rejected because Schemastery targets validation and transformation through Standard Schema rather than JSON Schema generation. It would add an adapter layer without producing the model-facing wire schema or the shared output vocabulary. - **Adopt full JSON Schema or Ajv:** rejected because the harness must fail on every construct it cannot project into its generated SDK and validators; accepting a larger language would make enforcement and model guidance dishonest. - **Make every object implicitly open or closed:** rejected because either choice hides a consequential author decision. Only the legacy-shaped implicit parameter root and external raw schema retain an intentional default. - **Define `oneOf` as first-match:** rejected because branch ordering would change validation semantics and allow overlapping branches to hide ambiguous values. @@ -32,4 +33,5 @@ Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent - Explicit object openness and type-correct literal constraints make malformed declarations fail during authoring or registration rather than during a later model call. - Bounded type inference retains useful exact types for ordinary declarations and degrades unusually deep tails to `JsonValue`; runtime schema enforcement remains exact at every depth. - Raw tools may still register broader JSON Schema directly, but unified code generation treats unsupported schemas as unknown instead of pretending to enforce them. +- Per-property `required: true` remains the tool-author contract, and type-level regression coverage pins required keys as non-optional after the original inference path exposed an optionality bug. - Runtime and compile-time tests cover every root, exact-one overlap/no-match behavior, raw open defaults, explicit openness, lossy JSON values, inference, deep nesting across core and dynamic projections, JSON-invisible dynamic keys, and exotic schema arrays. diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md index 00a7a19961..321136c31a 100644 --- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md @@ -21,6 +21,7 @@ Status: implemented ## 备选方案 - **保留两套独立的参数与结构化输出 schema 系统:**不予采纳。每新增一种输出结构,都必须分别修改类型推导、编译、校验和代码生成,而这种重复并未形成有意义的职责边界。 +- **使用 Schemastery 处理工具参数:**不予采纳。Schemastery 通过 Standard Schema 面向校验与转换,而不是生成 JSON Schema。采用它会增加一层适配器,却不能产出面向模型的协议 schema 或共享的输出词汇。 - **采用完整 JSON Schema 或 Ajv:**不予采纳。harness 必须拒绝所有无法投影到生成 SDK 和校验器中的结构;如果接受更大的语言子集,强制执行能力和模型指引就会与事实不符。 - **让所有对象默认开放或默认封闭:**不予采纳。这两种选择都会隐藏一项影响重大的作者决策。只有保持旧有形态的隐式参数根对象和外部原始 schema 才有意保留默认值。 - **把 `oneOf` 定义为首个匹配分支:**不予采纳。这样一来,分支顺序会改变校验语义,重叠分支也会掩盖值的歧义。 @@ -32,4 +33,5 @@ Status: implemented - 显式的对象开放方式和类型正确的字面量约束会让格式错误的声明在编写或注册阶段快速失败,而不是拖到后续模型调用时才失败。 - 有界类型推导会为常规声明保留有用的精确类型,并将异常深的尾部结构退化为 `JsonValue`;运行时 schema 强制执行在任意深度仍保持精确。 - 原始工具仍可直接注册范围更广的 JSON Schema,但统一代码生成会把不受支持的 schema 视为未知类型,不会假装自己能够强制执行。 +- 每个属性的 `required: true` 仍是工具作者契约;原有推导路径暴露可选性缺陷后,类型级回归覆盖会锁定必填键不得为可选。 - 运行时和编译期测试覆盖所有根类型、恰好匹配一个分支时的重叠/无匹配行为、原始 schema 的默认开放语义、显式开放方式、有损 JSON 值、类型推导、核心投影和动态投影中的深层嵌套、动态注册中 JSON 不可见的键,以及非普通 schema 数组。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml index a813a94c95..dbd259b67a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.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-19-windows-atomic-write-dacl-preservation.md: 013119508da9be426c417797cf7a0ec14e276814 -2026-07-19-windows-atomic-write-dacl-preservation.zh.md: 8ae82884c3b80409d07d3bbcfc8c273e8b227dc8 +2026-07-19-windows-atomic-write-dacl-preservation.md: be9f82174300a7d605c6a6e63878728f08cb37be +2026-07-19-windows-atomic-write-dacl-preservation.zh.md: fc6ec5232c992f3a230ee0de89439c869b8b46f9 diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md index 013119508d..be9f821743 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md @@ -6,13 +6,13 @@ English | [中文](2026-07-19-windows-atomic-write-dacl-preservation.zh.md) ## Problem -On Windows, creating the staging directory and temp file under the target's parent and relying only on inherited DACLs is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement. +Atomic writes protect POSIX staging directories with `0o700` and temp files with `0o600`, but Windows mode bits expose only a synthetic read-only view of the actual DACL. Creating staging under the target's parent and relying on inheritance is sufficient for a new file, but not for replacing an existing file whose explicit or protected DACL is narrower than its parent: content is written under the broader parent DACL, and rename carries that staging descriptor onto the replacement. ## Decision -`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New files have no prior descriptor to preserve and continue to inherit the destination directory's DACL. +`dsh-fs-local` reads an existing target's DACL with `GetFileSecurityW`, applies it to the empty temp file with inheritance protected before writing content, and publishes the closed temp with `ReplaceFileW`. The protected staging descriptor prevents the temp directory's inherited entries from broadening access; `ReplaceFileW` preserves the original target access policy and other replacement metadata. Its ACL merge may reserialize auto-inheritance state or duplicate equivalent ACEs, so self-relative descriptor buffers are not a stable equality contract. New Windows files have no prior descriptor to preserve and continue to inherit the destination directory's DACL; their staging directory therefore lives beside the target. POSIX keeps the owner-only staging modes and preserves an existing target mode. -Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. +Native Windows coverage protects a target DACL, inspects the written staging file, and compares the final replacement's ordered, de-duplicated ACE policy. Host-independent binding tests cover Win32 error translation and every native call boundary. Mode-bit assertions remain POSIX-only; new-file DACL inheritance is an operating-system contract rather than a machine-specific account allowlist. ## Alternatives considered @@ -22,6 +22,10 @@ Native Windows coverage protects a target DACL, inspects the written staging fil **Install an owner-only DACL for every write.** Rejected because it would discard deliberate project sharing. Copying the target DACL preserves the deployment's existing access policy instead of inventing one. +**Assert inherited accounts with `Get-Acl` or `icacls`.** Rejected because such a test verifies machine policy rather than package behavior, and localized well-known account names make the output unstable across hosts. + +**Skip the existing `chmod` calls on Windows.** Rejected because Node maps these writable modes to benign no-ops; platform guards add branches without changing DACL behavior. + ## Consequences -Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. New-file behavior remains directory-inherited, and POSIX mode behavior is unchanged. +Replacing a Windows file now requires permission to read the target DACL and set the temp DACL; failure is loud before content is written. The package carries Koffi for the narrow Win32 calls, loaded only on Windows replacement paths. A new Windows file inherits broad directory access when the directory is broad by design, while POSIX temp content stays owner-only; a read-only Windows target still fails publication before synthetic mode replay could matter. diff --git a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md index 8ae82884c3..fc6ec5232c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -在 Windows 上,在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。 +原子写入在 POSIX 上以 `0o700` 保护暂存目录、以 `0o600` 保护临时文件,但 Windows mode 位只呈现实际 DACL 的合成只读视图。在目标文件的父目录下创建暂存目录和临时文件,并且只依赖继承的 DACL,足以满足新建文件的需要,但无法安全替换显式或受保护 DACL 比父目录更严格的现有文件:内容会在权限更宽松的父目录 DACL 下写入,而重命名又会把这个暂存安全描述符带到替换后的文件上。 ## 决策 -`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新建文件没有既有描述符需要保留,因此仍继承目标目录的 DACL。 +`dsh-fs-local` 通过 `GetFileSecurityW` 读取现有目标文件的 DACL,在写入内容前将其以禁止继承的形式应用到空临时文件,并通过 `ReplaceFileW` 发布已关闭的临时文件。受保护的暂存安全描述符可防止暂存目录中的继承条目扩大访问权限;`ReplaceFileW` 会保留原目标文件的访问策略及其他替换元数据。其 ACL 合并过程可能重新序列化自动继承状态或复制等价 ACE,因此不能把自相对安全描述符缓冲区的逐字节相等作为稳定契约。新的 Windows 文件没有既有描述符需要保留,因此仍继承目标目录的 DACL;其暂存目录也因此位于目标文件旁。POSIX 继续使用仅所有者可访问的暂存 mode,并保留现有目标文件的 mode。 -Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。 +Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成的暂存文件,并对比最终替换文件中保持顺序且去重后的 ACE 策略。与宿主平台无关的绑定测试覆盖 Win32 错误转换以及每个原生调用边界。mode 位断言仍仅适用于 POSIX;新文件的 DACL 继承由操作系统契约规定,不应通过特定机器的账户允许列表来断言。 ## 备选方案 @@ -22,6 +22,10 @@ Windows 原生覆盖率测试会保护目标文件的 DACL、检查写入完成 **每次写入都设置仅所有者可访问的 DACL。** 不予采用,因为这会破坏项目有意设置的共享权限。复制目标文件的 DACL 可以保留部署中已有的访问策略,无需另行创设策略。 +**使用 `Get-Acl` 或 `icacls` 断言继承账户。** 不予采用,因为这类测试验证的是机器策略,而不是包行为;系统内置账户名会本地化,使输出在不同宿主上不稳定。 + +**在 Windows 上跳过现有 `chmod` 调用。** 不予采用,因为 Node 会把这些可写 mode 映射为无害的空操作;平台条件判断只会增加分支,不会改变 DACL 行为。 + ## 影响 -替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新建文件仍按目录继承,POSIX mode 行为保持不变。 +替换 Windows 文件现在要求调用方有权读取目标 DACL 并设置临时文件 DACL;如果权限不足,系统会在写入内容前明确失败。该包(package)引入 Koffi 以执行少量 Win32 调用,并且只在 Windows 替换路径上加载。新的 Windows 文件会在目录按设计开放较宽访问权限时继承该权限,而 POSIX 临时内容仍仅允许所有者访问;只读 Windows 目标文件仍会在发布时失败,早于重放合成 mode 可能产生影响的时点。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml deleted file mode 100644 index 26ef840cd0..0000000000 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-06-14-acp-agent-client-protocol.md: da23bbfa247bc2423072477cc4b6277485df1c9c -2026-06-14-acp-agent-client-protocol.zh.md: ec55922e0aee57169d8bbf44c3d91b18ea83041e diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md deleted file mode 100644 index da23bbfa24..0000000000 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Agent Client Protocol (ACP) support — drive the coding agent from external editors - -Status: implemented - -English | [中文](2026-06-14-acp-agent-client-protocol.zh.md) - -> Superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). This note records the retired editor-facing bridge design. - -## Problem - -The harness originally exposed agents only through a readline loop. That surface could carry text, but it gave an editor no structured way to create or resume sessions, correlate prompt completion, stream reasoning and tool activity, render tool-specific UI, ask for permission, or cancel one conversation without disturbing another. ACP defines those interactions as JSON-RPC over stdio, and Zed is the target client used to make concrete compatibility decisions. - -The bridge must preserve the harness's existing ownership boundaries. It cannot depend on the concrete agent loop, bypass the tool registry, execute shell commands in the editor, or invent a second source of session truth. stdout is also the protocol transport, so any accidental log output corrupts the connection. - -## Decision - -`@deepseek-ai/dsh-acp` was a UI/client-driver plugin in the `ui` package group (it now lives in `acp`). It used `@agentclientprotocol/sdk`'s `AgentSideConnection` over stdin/stdout and programmed only interface services: the agent create/resume factory, session persistence, tool registry, user interaction, and optional approval/bash capabilities. It did not change the agent loop and was not a capability-seam implementation. - -The bridge implements the following stable session path: - -- `initialize` negotiates the protocol version, advertises text plus `resource_link` prompts, and advertises `loadSession`. -- `session/new` validates an absolute `cwd`, stores it in `SessionHeader`, creates an agent through `ctx.agents`, and returns any composition-backed config options. -- `session/load` validates the requested cwd against persisted metadata before constructing an agent, reserves the id across the asynchronous resume, replays user/assistant/tool events as ACP updates, and reports the resumed config-option fold. -- `session/prompt` accepts text and resource links, rejects unsupported or empty content, allows one in-flight prompt per session, and settles against that prompt's owning `turn/end`. An error turn rejects the RPC; other closed turn reasons map through a total ACP stop-reason codec. -- `session/cancel` calls the queue-aware agent cancel path and settles only the addressed session's prompt. - -Tool-call presentation remains tool-owned. A tool's `presentCall` and `presentResult` return the `generic`, `terminal`, or `diff` render-intent variants; the bridge switches on that union and maps it to ACP. Presenter-less tools receive a generic fallback. Bash terminal cards use Zed's capability-gated `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit` convention; the harness still executes the command through `ctx.bash`, preserving sandbox, environment scrub, ownership, and cwd. Clients without that extension receive ordinary text content. Filesystem tools provide diff cards and file locations without hard-coded tool-name branches in the bridge. - -Permission handling is an answerer on the [user-approval seam](2026-07-06-approval-seam.md), not an ask-every-tool policy in ACP. An `approval/request` for a bridge-owned agent with a call id becomes `session/request_permission` on that agent's editor session, with one-shot allow/reject choices. Foreign or call-less requests delegate; a missing or failed answerer remains fail-closed. The plugin that asks—such as a pre-execute policy or bash escalation—owns the decision to ask. - -When `ctx.permission` is composed, the bridge exposes one `permission` select from the deployment's preset table. The shipped `workspace-write` and `danger-full-access` presets each bundle a sandbox mode with an approval policy; unmatched effective knobs produce the switch-away-only `custom` state. `session/set_config_option` validates through `PermissionService.set()` and writes both owning knob events. A switch during an open turn appends immediately; an idle switch is overlaid in responses and anchored at the next `agent/prompt-submit`, before request assembly. Until then it is memory-only, so a crash restores the durable fold. ACP session modes are not modeled because config options are the forward protocol surface; `AcpConfig.model` remains connection-wide. - -The bridge also provides the ACP-backed `UserInteractionProvider`: `ask_user_question` requests become form elicitations on the owning session. Select, multi-select, option descriptions, and custom-answer override semantics are preserved. - -Lifecycle ownership is explicit. The bridge holds an `AgentHandle` per live session. Disconnect and Cordis disposal cancel pending prompts, dispose every handle in parallel, await loop quiescence and persistence flush, and then remove the records. Stream notification failures are contained so a vanished client cannot corrupt an agent turn. The ACP app composition loads no stdout logger; a test guards stdout as framed JSON-RPC only. - -The current protocol contract lives in the [`dsh-acp` package README](../../../../packages/acp/acp/README.md). - -## Alternatives considered - -**A prepended `tools/execute` listener that asks on every ACP-owned call** — rejected. It would hard-code permission policy into the UI bridge, ask even when no policy requires it, and could not serve approval requests that arise after execution begins. The shared user-approval seam keeps mechanism, asking policy, and UI answerer separate. - -**Inject the concrete `agentLoop`** — rejected. Agent creation, resume, idle observation, and disposal are interface-level ownership operations on `dsh-agent`; a UI plugin does not need a dependency-rule exception. - -**Execute bash through ACP `terminal/*`** — rejected. That would move execution outside the harness and bypass its sandbox, credential scrub, task ownership, cwd resolution, and session log. Terminal metadata is presentation only. - -**Represent permission presets as ACP session modes** — rejected. The deployment-defined preset is already one config-option select, while session modes are the legacy surface slated for removal in ACP v2. - -**Hijack stdout defensively** — rejected. Process-wide monkey-patching is outside Cordis effect ownership and races the protocol transport. The app composition owns stdout purity. - -## Consequences - -Editors can create, load, prompt, cancel, render, ask, and reconfigure multiple harness sessions over one ACP connection without a loop-specific dependency. The session event log remains the durable source for replay, prompt settlement, cwd, and per-session configuration. Tool presentation and human-answer channels remain extensible plugin contracts instead of ACP-specific behavior. - -The bridge deliberately does not implement session list/delete/resume/close capabilities, MCP passthrough, additional directories, image/audio/embedded-resource prompts, plans, slash commands, usage updates, editor filesystem delegation, or the ACP terminal execution sub-protocol. Runtime model selection was added later through standard session config options by the [LLM catalog and ACP selection Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md). - -An idle config selection is truthful in the live response but not durable until the next `agent/prompt-submit` anchors it inside the open turn. Crashing before that boundary loses the pending selection; this is the cost of keeping session events turn-enclosed and replay-safe. - -## Verification - -The ACP suites cover the in-memory protocol codec, create/load replay, exact prompt settlement, cancellation races, unsupported content, tool presentation, terminal capability fallback, permission outcome mapping, config-option validation and persistence, multi-session isolation, disconnect/disposal quiescence, and HMR cleanup. Snapshot and built-bin tests exercise the app composition, while the real-API e2e self-skips without a key. diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md deleted file mode 100644 index ec55922e0a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Agent Client Protocol(ACP)支持——从外部编辑器驱动编码 agent - -Status: implemented - -[English](2026-06-14-acp-agent-client-protocol.md) | 中文 - -> 已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。本 Agent Note 记录已退役的面向编辑器的桥接层设计。 - -## 问题 - -harness 最初仅通过 readline 循环暴露 agent。该接口能传输文本,但编辑器无法以结构化方式创建或恢复会话、关联提示词完成、流式输出推理(reasoning)与工具活动、渲染工具专属 UI、请求权限,或在不干扰其他对话的前提下取消某个对话。ACP(Agent Client Protocol)将这些交互定义为基于 stdio 的 JSON-RPC,Zed 是用于做出具体兼容性决策的目标客户端。 - -桥接层必须保持 harness 既有的所有权边界。它不能依赖具体的 agent loop(智能体循环),不能绕过工具注册表,不能在编辑器中执行 shell 命令,也不能发明第二个会话真源。stdout 同时也是协议传输通道,因此任何意外的日志输出都会破坏连接。 - -## 决策 - -`@deepseek-ai/dsh-acp` 曾是 `ui` 包组中的 UI/客户端驱动插件(现位于 `acp`)。它使用 `@agentclientprotocol/sdk` 的 `AgentSideConnection`(基于 stdin/stdout),仅编排接口服务:agent 创建/恢复工厂、会话持久化、工具注册表、用户交互,以及可选的审批/bash 能力。它不修改 agent loop,也不是能力 seam 的实现。 - -桥接层实现以下稳定的会话路径: - -- `initialize` 协商协议版本,声明支持 text 与 `resource_link` 类型的提示词,并声明 `loadSession` 能力。 -- `session/new` 校验绝对路径 `cwd`,将其存入 `SessionHeader`,通过 `ctx.agents` 创建 agent,并返回由组合层支持的配置选项。 -- `session/load` 在构造 agent 之前校验请求的 cwd 与持久化元数据是否一致,在异步恢复期间保留 id,将用户/助手/工具事件作为 ACP update 回放,并报告恢复后的 config-option 折叠结果。 -- `session/prompt` 接受文本和 resource link,拒绝不支持的或空的内容,每个会话同时只允许一个 in-flight 提示词,并在该提示词所属的 `turn/end` 时结算。错误轮次拒绝 RPC;其他关闭轮次的原因通过一个全覆盖的 ACP stop-reason 编解码器映射。 -- `session/cancel` 调用队列感知的 agent 取消路径,仅结算被寻址会话的提示词。 - -工具调用的展示仍由工具自身负责。工具的 `presentCall` 和 `presentResult` 返回 `generic`、`terminal` 或 `diff` 渲染意图变体;桥接层对该联合类型做 switch 并映射到 ACP。没有 presenter 的工具获得通用回退。Bash 终端卡片使用 Zed 的能力门控约定 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit`;harness 仍通过 `ctx.bash` 执行命令,保留沙箱、环境清洗、所有权和 cwd。不支持该扩展的客户端收到普通文本内容。文件系统工具提供 diff 卡片和文件位置,桥接层中无需硬编码工具名分支。 - -权限处理是[用户审批 seam](2026-07-06-approval-seam.md)上的一个 answerer,而非 ACP 中的「每次工具调用都询问」策略。对桥接层所属 agent 且带有 call id 的 `approval/request`,会变为该 agent 编辑器会话上的 `session/request_permission`,提供一次性允许/拒绝选项。外部请求或无 call id 的请求委托给下游;缺失或失败的 answerer 会在故障时保持拒绝。发起询问的插件(如预执行策略或 bash 升级)拥有「是否询问」的决策权。 - -当 `ctx.permission` 被组合时,桥接层从部署的预设表中暴露一个 `permission` select。已发布的 `workspace-write` 和 `danger-full-access` 预设各自捆绑一个沙箱模式与一条审批策略;无法匹配的有效旋钮组合产生只能切走的 `custom` 状态。`session/set_config_option` 通过 `PermissionService.set()` 校验并写入两个所属旋钮事件。在开放轮次中的切换立即追加;空闲时的切换叠加在响应中,并在下一次 `agent/prompt-submit` 时锚定到开放轮次之前的请求组装阶段。在此之前它仅存于内存,因此崩溃后恢复的是持久化的折叠结果。ACP session mode 不被建模,因为 config option 是面向未来的协议表面;`AcpConfig.model` 保持连接级别。 - -桥接层还提供基于 ACP 的 `UserInteractionProvider`:`ask_user_question` 请求变为所属会话上的表单引导。select、multi-select、选项描述与自定义回答覆盖语义均被保留。 - -生命周期所有权是显式的。桥接层为每个活跃会话持有一个 `AgentHandle`。断连和 Cordis dispose(资源释放)会取消待处理的提示词,并行 dispose 所有 handle,等待循环完全停稳与持久化刷写,然后移除记录。流通知失败被隔离,因此消失的客户端不会破坏 agent 轮次。ACP 应用组合不加载 stdout logger;一个测试守卫 stdout 仅包含帧化的 JSON-RPC。 - -当前的协议契约见 [`dsh-acp` 包 README](../../../../packages/acp/acp/README.md)。 - -## 曾考虑的替代方案 - -**在 `tools/execute` 监听器前置一层,对每个 ACP 所属调用都询问权限**:否决。这会将权限策略硬编码到 UI 桥接层,即使没有策略要求也会询问,且无法服务于执行开始后才产生的审批请求。共享的 user-approval seam 将机制、询问策略和 UI answerer 分离。 - -**注入具体的 `agentLoop`**:否决。agent 的创建、恢复、空闲观察与释放是 `dsh-agent` 上的接口级所有权操作;UI 插件不需要依赖规则例外。 - -**通过 ACP `terminal/*` 执行 bash**:否决。这会将执行移到 harness 之外,绕过其沙箱、凭证清洗、任务所有权、cwd 解析与会话日志。终端元数据仅用于展示。 - -**将权限预设表示为 ACP session mode**:否决。部署定义的预设已经是一个 config-option select,而 session mode 是 ACP v2 计划移除的遗留接口。 - -**防御性劫持 stdout**:否决。进程级 monkey-patching 超出 Cordis 副作用所有权范围,且与协议传输存在竞争。应用组合拥有 stdout 纯净性。 - -## 后果 - -编辑器可以通过一条 ACP 连接创建、加载、提交提示词、取消、渲染、询问和重新配置多个 harness 会话,无需依赖特定的循环实现。会话事件日志仍是回放、提示词结算、cwd 与每会话配置的持久真源。工具展示与人工回答通道仍是可扩展的插件契约,而非 ACP 专属行为。 - -桥接层有意不实现会话列表/删除/恢复/关闭能力、MCP 透传、附加目录、图片/音频/嵌入资源提示词、plan、斜杠命令、用量更新、编辑器文件系统委托或 ACP 终端执行子协议。后续已通过标准会话配置选项加入运行时模型选择,见 [LLM 目录与 ACP 选择 Agent Note](../architecture/2026-07-15-llm-model-catalog-and-acp-selection.md)。 - -空闲时的配置选择在实时响应中是真实的,但在下一次 `agent/prompt-submit` 将其锚定到开放轮次之前不具持久性。在该边界之前崩溃会丢失待定选择;这是保持会话事件封闭于轮次内且回放安全的代价。 - -## 验证 - -ACP 测试套件覆盖内存协议编解码器、创建/加载回放、精确的提示词结算、取消竞争、不支持的内容、工具展示、终端能力回退、权限结果映射、config-option 校验与持久化、多会话隔离、断连/释放后的完全停稳,以及 HMR(热模块替换)清理。快照测试与 built-bin 测试验证应用组合,真实 API 的 e2e 测试在无 key 时自动跳过。 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml deleted file mode 100644 index f9049645c3..0000000000 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-06-18-acp-terminal-and-tool-rendering.md: e8426dbf1a0e3e4f9d9857baa19945cee6eee4b3 -2026-06-18-acp-terminal-and-tool-rendering.zh.md: ff269e002a0ecea8c0bacf53fe85e553a6b9b9d5 diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md deleted file mode 100644 index e8426dbf1a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: Rich ACP bash rendering — the terminal card via the `_meta` convention - -Status: implemented - -English | [中文](2026-06-18-acp-terminal-and-tool-rendering.zh.md) - -> Superseded for ACP by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). Tool render intents remain available to UI transports, but ACP no longer projects them into terminal cards. - -## Problem - -The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](2026-06-14-acp-agent-client-protocol.md) and `packages/core/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. - -Reference editors render terminal metadata as a dedicated card with cwd, command, live-style output, and exit status; plain text loses that structure. The command is the title because execute cards hide raw input, while the human-readable description remains a separate block above the card. - -## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` - -The ACP spec has a *client-side* terminal sub-protocol — the agent calls the client's `terminal/create` with `{ command, args, cwd, env }` and the **editor** executes the process, then the agent reads `terminal/output` / `wait_for_exit`. That model is wrong for us: our harness executes bash itself through `dsh-bash` (sandboxed env-scrub, background-task ownership, per-session cwd). Routing execution to the editor would bypass all of that and fork execution into two backends. - -Studying the two reference agents (2026-06-18) shows neither uses `terminal/create` for their own shell tool — **both keep agent-side execution and emit a `_meta` convention** that Zed special-cases: - -- **`claude-agent-acp`** (`tools.ts`, `acp-agent.ts`): gated on `clientCapabilities._meta.terminal_output`. The `tool_call` carries `content: [{ type: 'terminal', terminalId }]` and `_meta.terminal_info.{ terminal_id, cwd }`; output/exit arrive on the `tool_call_update`'s `_meta.terminal_output.{ terminal_id, data }` and `_meta.terminal_exit.{ terminal_id, exit_code, signal }`. -- **`codex-acp`** (`CodexToolCallMapper.ts`, `TerminalOutputMode.ts`): same `terminal_info` on the call; output via `_meta.terminal_output` (full) or `_meta.terminal_output_delta` (incremental), selected from the same `_meta.terminal_output` capability. - -Zed's side (`crates/agent_servers/src/acp.rs`, verified): on a `ToolCall` whose `_meta.terminal_info.terminal_id` is set, it registers a **display-only** terminal (header = `terminal_info.cwd`, label = `tool_call.title`); on a `ToolCallUpdate`, `_meta.terminal_output.data` writes to that terminal and `_meta.terminal_exit.{exit_code,signal}` sets the status. It advertises the capability as `clientCapabilities._meta.terminal_output = true`. `_meta` itself is a spec-blessed ACP extensibility point (typed `{[k]: unknown} | null` on `ToolCall`/`ToolCallUpdate`); the *specific keys* here (`terminal_info`/`terminal_output`/`terminal_exit`) are a Zed convention, not part of the ACP spec — but they are the de-facto contract for the Zed integration and the only way to get the terminal card while keeping execution agent-side. - -## Decision - -Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` convention, capability-gated, with the ` ```console ` text block as the guaranteed fallback. - -1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection. -2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result). -3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged. -4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal. - -## Alternatives considered - -- **The ACP client-side terminal sub-protocol (`terminal/create`)** — explicitly rejected: the editor would execute the process, bypassing `dsh-bash`'s env scrub, background-task ownership, and per-session cwd, and forking execution into two backends. Both reference agents reject it the same way (the key finding above); agent-side execution plus the `_meta` convention is the only shape that yields the terminal card while keeping the harness's execution policy. -- **Threading a structured exit through the event schema** — rejected in favor of the marker round-trip: the pure `presentResult(args, result)` seam sees only content blocks, and the parse is the exact inverse of the marker emission, co-evolving in one file under a round-trip test. - -## Consequences - -- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. -- **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path. -- **terminalId collisions.** Deriving it from the per-call `callId` keeps it unique within a session and stable across the call/result pair; never reuse one across calls. -- **Exit parsed from rendered text.** The exit pill recovers `exit_code`/`signal` by parsing `renderResult`'s status markers rather than threading a structured exit through the event schema (which the pure `presentResult` seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead. -- **Provider-neutral vocabulary creep.** The terminal presentation widens the `dsh-tools` surface; keep it neutral (no ACP types leak into `dsh-tools`) and only as rich as a second UI consumer would also want. - -## Out of scope / non-goals - -The text-block baseline stays the no-capability default. Two follow-ups are deliberately NOT built here and would each warrant their own Agent Note when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). diff --git a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md b/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md deleted file mode 100644 index ff269e002a..0000000000 --- a/.agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 富 ACP bash 渲染——通过 `_meta` 约定实现终端卡片 - -Status: implemented - -[English](2026-06-18-acp-terminal-and-tool-rendering.md) | 中文 - -> 就 ACP 而言已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。工具渲染意图对 UI 传输层仍然可用,但 ACP 不再将其投影为终端卡片。 - -## 问题 - -ACP(Agent Client Protocol)桥接层允许每个工具通过 `presentCall`/`presentResult` 自行控制调用渲染(见[工具调用 UI 呈现](2026-06-14-acp-agent-client-protocol.md)与 `packages/core/tools`)。对于 `bash`,我们将确切命令作为 `tool_call` 标题呈现,模型的 `description` 作为一个内容文本块,`kind: 'execute'`,完成后的输出包裹在 ` ```console ` 围栏文本块中。 - -参考编辑器将终端元数据渲染为一张专用卡片,包含 cwd、命令、实时风格的输出和退出状态;纯文本则丢失了这些结构。命令之所以作为标题,是因为执行卡片隐藏原始输入,而人类可读的描述保留为卡片上方的独立块。 - -## 关键发现:agent 执行的终端使用 `_meta` 约定,而非 `terminal/create` - -ACP 规范有一个*客户端侧*终端子协议:agent(智能体)调用客户端的 `terminal/create`(传入 `{ command, args, cwd, env }`),由**编辑器**执行进程,然后 agent 读取 `terminal/output` / `wait_for_exit`。这个模型不适合我们:我们的 harness 通过 `dsh-bash` 自行执行 bash(沙箱化的环境清理、后台任务所有权、按会话的 cwd)。将执行路由到编辑器会绕过所有这些机制,并将执行分叉到两个后端。 - -研究两个参考 agent(2026-06-18)发现,二者都没有为自己的 shell 工具使用 `terminal/create`——**两者都保持 agent 侧执行,并发出一套 `_meta` 约定**,由 Zed 特殊处理: - -- **`claude-agent-acp`**(`tools.ts`、`acp-agent.ts`):以 `clientCapabilities._meta.terminal_output` 为门控。`tool_call` 携带 `content: [{ type: 'terminal', terminalId }]` 与 `_meta.terminal_info.{ terminal_id, cwd }`;输出和退出通过 `tool_call_update` 的 `_meta.terminal_output.{ terminal_id, data }` 与 `_meta.terminal_exit.{ terminal_id, exit_code, signal }` 到达。 -- **`codex-acp`**(`CodexToolCallMapper.ts`、`TerminalOutputMode.ts`):调用上同样携带 `terminal_info`;输出通过 `_meta.terminal_output`(完整)或 `_meta.terminal_output_delta`(增量),由同一个 `_meta.terminal_output` 能力选择。 - -Zed 侧(`crates/agent_servers/src/acp.rs`,已验证):收到 `ToolCall` 且其 `_meta.terminal_info.terminal_id` 已设置时,注册一个**仅展示**的终端(header = `terminal_info.cwd`,label = `tool_call.title`);收到 `ToolCallUpdate` 时,`_meta.terminal_output.data` 写入该终端,`_meta.terminal_exit.{exit_code,signal}` 设置状态。客户端通过 `clientCapabilities._meta.terminal_output = true` 声明此能力。`_meta` 本身是 ACP 规范认可的扩展点(在 `ToolCall`/`ToolCallUpdate` 上类型为 `{[k]: unknown} | null`);这里的*具体键*(`terminal_info`/`terminal_output`/`terminal_exit`)是 Zed 约定,不属于 ACP 规范,但它们是 Zed 集成的事实契约,也是在保持 agent 侧执行的前提下获得终端卡片的唯一方式。 - -## 决策 - -保持 `dsh-bash` 的 agent 侧执行;通过 `_meta` 约定渲染终端卡片,以能力声明为门控,以 ` ```console ` 文本块作为保底回退。 - -1. **能力声明。** `initialize` 读取 `clientCapabilities._meta.terminal_output`,桥接层按连接记住它。 -2. **提供方无关的展示词汇。** `dsh-tools` 新增一种终端形态的展示结构,工具可返回它——提供方无关(`cwd`、输出 `data`、`exitCode`/`signal`),不含 ACP 类型。`dsh-tool-bash` 为 `bash` 返回该结构(cwd 来自解析后的工作目录;输出与退出从运行结果解析)。 -3. **桥接映射。** 当客户端声明了该能力时,桥接层将展示结构映射为:在 `tool_call` 上,`content:[…, {type:'terminal', terminalId}]`(工具的任何 `content`,如描述,渲染在终端块之前)+ `_meta.terminal_info.{terminal_id,cwd}`;在 `tool_call_update` 上,`_meta.terminal_output.{terminal_id,data}`(捕获的输出)+ `_meta.terminal_exit.{terminal_id, exit_code|signal}`(解析后的退出),且 update 的文本 `content` 被省略(ACP 的 `tool_call_update.content` 会替换调用的 content 集合,因此重新发送围栏块会覆盖终端内容块)。`terminalId` 由 harness 的 `callId` 派生(稳定、每次调用唯一)。当能力未声明时,桥接层在调用上发送描述内容块,在 update 上发送既有的 ` ```console ` 文本内容——行为不变。 -4. **退出信息从渲染输出中解析;无新执行路径,无实时流式传输。** 输出在完成时附加(来自 agent 自身的 `tool/result`),不逐 token 流式传输。退出状态(`_meta.terminal_exit.{exit_code,signal}`)确实会发出:纯 `presentResult(args, result)` seam 只能看到内容块,因此 `dsh-tool-bash` 通过解析 `renderResult` 追加的状态标记(`[exit code: N]` / `[killed by signal: …]`)来恢复结构化退出信息——解析是标记发出的精确逆操作,二者在同一文件中共同演进,一个往返测试守护这对关系。资源释放不受影响:无需新增拆除逻辑,因为桥接层从未创建客户端侧终端。 - -## 曾考虑的替代方案 - -- **ACP 客户端侧终端子协议(`terminal/create`)**:明确否决。编辑器将执行进程,绕过 `dsh-bash` 的环境清理、后台任务所有权和按会话的 cwd,并将执行分叉到两个后端。两个参考 agent 以同样的方式否决了它(见上述关键发现);agent 侧执行加 `_meta` 约定是在保持 harness 执行策略的同时获得终端卡片的唯一形态。 -- **通过事件 schema 传递结构化退出信息**:否决,改用标记往返方案。纯 `presentResult(args, result)` seam 只能看到内容块,而解析是标记发出的精确逆操作,二者在同一文件中共同演进,由往返测试守护。 - -## 后果 - -- **Zed 约定的 `_meta` 键。** 终端卡片依赖 Zed 特有的键(`terminal_info`/`terminal_output`/`terminal_exit`),位于 ACP 规范认可的 `_meta` 扩展点内,而非 ACP 终端子协议。不识别这些键的客户端仍然获得文本回退(能力门控确保我们仅在客户端通过 `_meta.terminal_output` 声明支持时才发出这些键),因此非 Zed 客户端不会变差。如果 ACP 日后标准化了 agent 执行的终端,则迁移到该标准并移除约定键。 -- **能力诚实。** 仅在客户端声明了 `_meta.terminal_output` 时才发出终端元数据;文本回退是对其他所有客户端的契约,绝不可退化。由一个无能力测试覆盖,断言 ` ```console ` 路径。 -- **terminalId 冲突。** 从每次调用的 `callId` 派生,保证在会话内唯一且在 call/result 对之间稳定;绝不跨调用复用。 -- **退出信息从渲染文本解析。** 退出信息通过解析 `renderResult` 的状态标记恢复 `exit_code`/`signal`,而非通过事件 schema 传递结构化退出(纯 `presentResult` seam 看不到后者)。解析是标记发出的精确逆操作,且位于同一文件中;往返测试固定了这对关系,标记格式变更若破坏解析则测试套件失败。如果标记格式日后需要与退出信息分道扬镳,则改为在 result 事件上暴露结构化退出。 -- **提供方无关词汇的蔓延。** 终端展示结构扩大了 `dsh-tools` 的接口面;保持其中立性(不让 ACP 类型泄漏到 `dsh-tools`),且只提供第二个 UI 消费方同样需要的丰富度。 - -## 超出范围 / 非目标 - -文本块基线仍为无能力声明时的默认行为。以下两项后续工作有意不在此处构建,各自需要单独的 Agent Note:**实时增量流式传输**(在分片到达时发出 `_meta.terminal_output_delta`,需要在 `dsh-bash` 上新增增量输出 seam);**命令分类**(将 `cat`/`sed` 解析为带文件位置的 `read` 卡片,将 `grep` 解析为 `search`,回退到终端卡片——仅展示,绝不改变实际执行内容)。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index 6ac2ad45f6..d225ddf2d5 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.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-06-approval-seam.md: 852108f22be22eeba4578032924adb546ee10985 -2026-07-06-approval-seam.zh.md: 9a08f333e0859e6d71f40b039f4b441028c38dc3 +2026-07-06-approval-seam.md: 70ccd4d486ad6e0126fa2eb638a064e9fc89bba6 +2026-07-06-approval-seam.zh.md: d218f79888957735305db14cd97cc74480297d29 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 852108f22b..70ccd4d486 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -69,7 +69,7 @@ The seam also owns the session-scoped `'ask' | 'never'` policy described by [the The ACP bridge answers only for an exact agent object owned by its session map. It sends `session/request_permission` with the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. This channel is machine policy between an automated client and its agent, not ACP presentation. -The answerer routes through the bridge's exact-agent ownership check described by [the ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md). +The answerer routes through the bridge's exact-agent ownership check described by [the automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md), preserving the per-session permission ownership required by [the multi-session Agent Note](2026-06-14-acp-multi-session.md). #### Audit, and what the model sees @@ -135,5 +135,5 @@ In-repo precedents this design copies or contrasts with: - The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses. - `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges Agent Note](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer. - [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services. -- [The ACP support Agent Note](2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. +- [The automation-only ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md) — the exact-agent ownership check against the session map that the answerer routes through; [the multi-session Agent Note](2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. - The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 9a08f333e0..d218f79888 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -69,7 +69,7 @@ seam 还拥有[沙箱 Agent Note](2026-07-06-sandbox.md) 所描述的会话级 ` ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 `callId` 发送 `session/request_permission`,声明一次性的 allow/reject 选项,单独映射取消,并且绝不批准未知选项。外部或无调用标识的请求会委派;客户端 RPC 失败变为 `unavailable`。钩子和 `tools/pre-execute` 决定一次调用是否需要询问。该通道是自动化客户端与其 agent 之间的机器策略,不是 ACP 展示层。 -应答者通过 [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md) 描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 +应答者通过[仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)描述的桥精确 agent 归属检查进行路由,保留了[多会话 Agent Note](2026-06-14-acp-multi-session.md) 要求的每会话权限归属。 #### 审计,以及模型看到什么 @@ -135,5 +135,5 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - `fs/write-intent` 门禁(`packages/fs/fs/`)——文档化的单占用决策槽 waterfall 语义(先到先得,通过 `next()` 委派),应答者契约复用了它。 - `hook/invoked`/`hook/result`——仅日志审计对先例,`approval/asked`/`approval/decided` 沿用了它;[钩子桥 Agent Note](2026-06-30-hook-bridges.md) 交付了 `permissionDecision: ask`,即第一个生产者。 - [拦截 seam Agent Note](2026-06-30-interception-seams.md)——`tools/pre-execute` 的 `allow`/`deny`/`ask` 词汇,本 seam 服务其中的 `ask`。 -- [ACP 支持 Agent Note](2026-06-14-acp-agent-client-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 +- [仅面向自动化的 ACP Agent Note](../simplification/2026-07-23-acp-automation-only-protocol.md)——应答者路由时对会话映射执行的精确 agent 归属检查;[多会话 Agent Note](2026-06-14-acp-multi-session.md)——本设计实现的每会话权限归属阻塞项。 - 机会性 `ctx.get()` 消费模式(`tool-bash` 的 owner-token 查找、loop 的持久化探测)——`dsh-tools` 消费该 seam 而不阻塞其 fiber 的方式。 diff --git a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md b/.agents/notes/implemented/feature/2026-07-07-plan-mode.md deleted file mode 100644 index f5b76fae5b..0000000000 --- a/.agents/notes/implemented/feature/2026-07-07-plan-mode.md +++ /dev/null @@ -1,194 +0,0 @@ -# Agent Note: Plan mode — a logged per-agent session mode - -Status: implemented - -> **Superseded vocabulary (2026-07-22):** [Collapse named session modes into plan mode](../simplification/2026-07-22-plan-specific-collaboration-state.md) replaces this note's generic `dsh-mode`, `mode/set`, definition map, and `ctx.modes` design with the current plan-specific `dsh-plan-mode`, `plan/mode`, `{ section }`, and `ctx.planMode` contract. The review, boundary, reconstructability, and sandbox-orthogonality decisions below remain in force; generic API examples are retained as the historical design this simplification removed. - -> **Superseded ACP mapping:** [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) removes the picker, config-option, and elicitation mappings described below. Plan mode remains available to human-facing interfaces. - -## Problem - -Before this change, the harness had no durable way to put one agent into a distinct working stance. Plan mode needs the agent to explore and design under planning guidance, produce a reviewable artifact, cross an explicit approval boundary, and restore that state across resume and fork without making the model-visible request diverge from the session log. - -The extension seams already supplied the surrounding pieces: [`system-prompt/assemble`](../../../../packages/core/system-prompt/README.md) shapes guidance per step and the shipped request is logged in `request/header*` events ([reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md)); [`ctx.userInteraction`](../../../../packages/ui/user-interaction/README.md) carries the approval question and corrective feedback ([ask-user precedent](../../implemented/feature/2026-06-25-ask-user-question.md)); `SessionEventMap` carries durable per-agent facts ([the `todo/write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)). The missing piece was the named session state that joins those seams while leaving execution enforcement on the independent sandbox and approval axes. - -## Decision - -The deliverable is **plan mode**. It ships as the first **session mode** — a named, logged, per-agent COLLABORATION state: a mode definition is deployment-configured guidance the model sees, while the mode IN FORCE for an agent is session state folded from its log. Modes are one axis and the enforcement knobs — the sandbox mode, the approval policy — are others: they never read or write each other, matching how Codex keeps its Plan/Default collaboration presets separate from its sandbox and approval settings. One new product package, `@deepseek-ai/dsh-mode` at `packages/mode/mode/`, owns the event vocabulary, a thin `ctx.modes` service, and every listener; the loop does not change. `plan` is the only required definition — the mode-shaped vocabulary exists so a second mode never renames durable event types, not because more modes ship now. - -The state is one `SessionEventMap` member: **`mode/set`**, a log-only, non-surface event carrying `{ mode: string }` with whole-value-replace semantics, plus a pure `foldMode(events)` that returns the mode in force — the last `mode/set`, or the default mode when none exists. Because [the log is the fact channel](../../implemented/architecture/2026-06-30-event-domain-semantics.md), resume, fork, and compaction restore the mode with no extra machinery, and UIs read flips off `session/event`. The default mode is the absence of mode guidance — no section, filtering, or gate. Loading `dsh-mode` still contributes one stable `exit_plan_mode` schema in every mode; that fixed cost avoids tool-catalog churn at mode boundaries. - -A mode's whole surface is soft: a `mode:policy` prompt section renders the active definition's guidance, while `exit_plan_mode` remains in the registered tool catalog across every mode and rejects at execution unless the folded mode is `plan`. A transition therefore changes only the system-prompt portion of the attributable `request/header` on the next step, keeping [reconstructability](../../implemented/architecture/2026-07-05-reconstructable-requests.md) green without changing native schemas or Code Mode's SDK. A mode deliberately enforces NOTHING: no execution gate, no tool filtering, no reach into the sandbox or approval knobs — a user who wants a hard read-only floor while planning switches the sandbox-mode option beside the mode picker, in either order, and neither axis disturbs the other. There is likewise NO per-mode tool allow/deny list — which tools a mode admits is an effects question, parked until tool definitions declare their effects ([Deferred](#deferred)); a mode's restraint is its section's guidance plus the exit review. - -The model leaves plan mode through the **`exit_plan_mode`** tool: its single argument is the plan text, which makes the plan reconstructable from the log, and the tool conducts the review itself through the user-interaction seam — a question whose supporting detail carries the exact plan, with options and a free-text channel, not a bare permission — so an approval flips the logged mode back to the default, and a rejection becomes the corrective error carrying the user's feedback verbatim, which keeps the model planning with direction. A user flips the mode from any surface through `ctx.modes.set()`; the flip is applied at the next turn boundary (session events are turn-enclosed) and narrated to the model once, only when the model-visible state actually changed. - -## High-level API - -### A plan-mode session end to end - -The user switches the session to plan mode through the ACP mode picker or `/plan [message]` in a terminal front door, and from the next step every request ships the configured plan guidance section. When the optional message is present, that same command submits it into the affected step. The `exit_plan_mode` schema was already present in default and remains byte-identical. - -The model explores and designs; the section's guidance is what defers changes into the plan. The sandbox and approval knobs keep whatever the user set them to — a deployment (or user) that wants kernel-enforced read-only during planning pairs plan mode with the independent sandbox-mode option. - -When ready, the model calls `exit_plan_mode` with the plan markdown as its argument; the review question carries that exact markdown as supporting detail — approve, or keep planning, with free-text feedback welcome. A native call also renders the plan card; a Code Mode nested dispatch has no native card, so the review detail is the common presentation surface. - -On approve, the tool flips the logged mode back to the default: the next step drops the plan section while retaining the same tool catalog (the changed header is in the log), and execution tracking from there is already `todo_write`'s job. On keep-planning, the model receives a corrective error carrying the user's feedback text, revises, and re-presents. - -### Deployment configuration - -Mode definitions are validated plugin Config — per repo convention, changeable from `cordis.yml` with no code edit. The deployment must provide the complete `plan` section; the package embeds no model instructions. Additional modes use the same config map: - -```yaml -- id: mode - name: '@deepseek-ai/dsh-mode' - config: - modes: - plan: - section: | - You are in plan mode: explore and design, then present the - plan for approval through exit_plan_mode. -``` - -A definition is exactly `{ section }` — there is deliberately no per-mode tool list and no enforcement field ([FAQ](#faq)). Definition names use the lowercase slash-command subset `/^[a-z][a-z0-9_-]*$/u`; `default` is reserved (the absence of policy) and rejected as a key. An invalid name or unknown definition key — a `tools` list or an `access` cap included — fails validation at load; an unknown mode name fails loudly at `set()` time. - -### In the terminal - -Terminal front doors get one entry command per configured definition through the plugin-owned command registry (`@deepseek-ai/dsh-commands`): `dsh-mode` registers `/plan [message]` for the required definition and, for example, `/review [message]` when `review` is configured. Each command records its named switch; a non-empty optional message is trimmed and passed to `agent.steer()`, which places it in a running agent's next step or delegates to `send()` for a new idle turn. The command name and result stay out of model history, while that explicit message is logged as an ordinary user message under the selected mode. The synthetic `default` entry contributes no command. The exit review prompts right in the terminal with no new machinery: it is an ordinary user-interaction question, so it rides the composed user-interaction provider's prompt queue that `ask_user_question` already uses. - -### Over ACP - -The mode PICKER is this package's surface: `session/new`/`session/load` advertise `availableModes`/`currentModeId` from `ctx.modes` (consumed opportunistically via `ctx.get`, the `tool-bash` pattern), `session/set_mode` calls `set()` and notifies `current_mode_update` optimistically (the pending mode IS the user's selection; the logged `mode/set` follows at the boundary), and a `session/event` listener re-notifies on each logged flip that differs from the last sent. The exit tool reuses the user-interaction ACP provider's elicitation flow; its ACP mapping carries the review `detail` because Code Mode nested dispatches have no native plan card, while native calls may additionally stream the plan card. Individual environment knobs — sandbox mode, approval policy, the model — are NOT modes and belong to `session/set_config_option` ([FAQ](#faq)). - -### For agent creators - -`ctx.modes` is the whole programmatic surface: `list()` returns the configured definitions plus the synthetic `default` entry (for pickers), `get(agent)` returns the folded mode plus any pending intent, and `set(agent, mode)` validates the name against `list()`'s vocabulary and records the boundary-applied intent — `default` is always a valid target, so exiting a mode is the same call as entering one. There is no creation-time mode option — a caller selects through `set()` before the first turn, which flushes identically. There is no live `agent/*` mirror to subscribe: UIs read `mode/set` off `session/event`, per [event-domain semantics](../../implemented/architecture/2026-06-30-event-domain-semantics.md). - -## Detailed design - -### Vocabulary - -```text -'mode/set': { mode: string } // SessionEventMap merge in dsh-mode: log-only, non-surface, - // whole-value replace — the last one in the log wins -DEFAULT_MODE = 'default' // the fold of a log with no mode/set; reserved, not definable -``` - -The payload carries no reason/provenance field: a tool-driven flip sits next to its `tool/call` in the log and a user flip sits at its turn boundary, so the cause is log-adjacent — the same "narrative fields are derivable" call the [reconstructability Agent Note](../architecture/2026-07-05-reconstructable-requests.md) made for request-header facts (the in-flight `env/state` event carries a `source` precisely because its drift variant has NO log-adjacent cause — a contrast, not a conflict). Mode names are config-declared vocabulary, not opaque cross-boundary ids, so they stay bare strings (no `Branded`). - -### Config and the resolve step - -```text -interface ModeDefinition { section: string } // prompt text — a mode's whole vocabulary -interface ModeConfig { modes: Record } // plan is required and owns its complete prompt -resolveConfig(config): ResolvedModes // explicit resolve (the dsh-bash template), fail-loud: - // missing plan, 'default', blank sections, and unknown keys rejected -``` - -The one-field shape is deliberate minimalism, not the final vocabulary: a per-tool policy dimension returns as effects metadata on tool definitions ([Deferred](#deferred)), read here rather than re-declared per mode — the config shape must not need a migration when it arrives. - -### The fold, the service, and the flush - -`foldMode(events)` is pure (exported for reconstructors and tests) and folds the append-only session log directly; `mode/set` is not a surface node, so compaction cannot shadow it. `set(agent, mode)` validates the name against `list()`'s vocabulary — the configured definitions plus the reserved `default`, which is rejected as a config KEY but always accepted as a `set()` TARGET — drops a no-op (target equals pending, else current), and otherwise records `{ mode, narrate }` in a `WeakMap` pending-intent slot. It cannot append immediately because [every session event is turn-enclosed](../../implemented/architecture/2026-06-15-turn-enclosure-invariant.md) and an idle agent has no open turn. - -Contained listeners on the loop's interception seams ([defensive patterns](../../../../docs/defensive-patterns.md): a policy plugin must not block a prompt or a turn) flush the pending intent as a `mode/set` append — `agent/prompt-submit` fires inside the just-opened turn before its first assembly, and `agent/turn-continuation` fires after an ordinary step closes before its successor. Automatic request recovery bypasses continuation, so a prepended `agent/request-error` wrapper delegates through the composed policy and asynchronous backoff, then flushes only a `retry` decision before the waterfall returns to the loop; an effect-scoped lifetime guard suppresses a captured wrapper that resumes after plugin disposal. All three paths sit outside tool execution and log publication (post-commit `session/event` observers are observe-only), so every step runs under the mode its assembly folded. When the flushed mode differs from the fold at the last `request/header`, the flush appends one coalesced `context/message` notice in the same frame ("The user switched this session to plan mode."); the user-visible narration cases are enumerated in the [FAQ](#faq). - -### The soft layer: a computed section and a stable exit schema - -The registered prompt section reads the calling agent's mode from `AssembleContext.agent` and resolves to the active definition's guidance or `''`. The loop renders per step and logs a complete `request/header` whenever the rendered header changes, so entering or leaving a mode is attributable. The section is static per mode and the plan itself stays in the conversation as messages and tool arguments; re-injecting separate plan state on every request ([Prior art](#prior-art)'s compaction-survival hack) is unnecessary prompt churn. - -The guidance contribution is `{ name: 'mode:policy', order: 50, text: context => … }`: after persona (0), before tool guidance (100–199), and empty for default or agent-less assembly. `exit_plan_mode` is registered once through `ctx.tools` and never filtered, so native schemas and Code Mode's generated SDK remain byte-identical across mode switches; a deployment without `dsh-mode` lacks that one binding. There is NO `tools/pre-execute` listener: a mode gates nothing, while the exit tool's own folded-mode check rejects out-of-plan calls. The exit review is a question with options and feedback, not a permission, so it lives inside the tool's execution over the user-interaction seam. - -### `exit_plan_mode` - -`defineTool` has one required `plan: string` argument. Native execution records it in the ordinary `tool/call`; Code Mode records the outer `run_code` source before execution and appends the normalized nested arguments in `tool/code-dispatch` after the dispatch settles. `execute` rejects an agent-less call (the [`todo_write` precedent](../../implemented/feature/2026-06-29-todo-write-tool.md)), rejects any folded mode other than `plan`, rejects an empty or heading-less plan before asking the reviewer, then conducts one single-select `ctx.userInteraction.ask()` review whose `detail` is the exact plan — approve or keep planning — with free-text feedback open. Only exactly one `Approve` selection consents; every other shape fails closed. Approval records a SILENT boundary-applied intent to switch to `default` and returns a short confirmation. The deployment guidance tells the model to make this the only and final tool call in its response; if a model violates that rule, the runtime still holds plan guidance for the rest of the batch, and the next step logs a changed header with the guidance removed and tool schemas unchanged. Every non-approval outcome returns a corrective `isError` and leaves the mode in `plan`. - -Its [render intent](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), decided up front: `presentCall` is a `generic` card titled by the plan's first heading with the plan markdown as content, plus a `generic` result card. Native front doors show that card before the question; Code Mode nested dispatches do not produce native call-card events, so the user-interaction `detail` independently carries the same plan on every provider. The seam is consumed opportunistically (`ctx.get('userInteraction')`), so `dsh-mode` composes without it and degrades to the manual exit pinned in the [FAQ](#faq). - -### Dependencies and surfaces - -`dsh-mode` is one product package, not a capability-seam trio ([Alternatives considered](#alternatives-considered)): it peers on `cordis`, `dsh-session`, `dsh-agent`, `dsh-tools`, and `dsh-system-prompt`, injects `['tools', 'systemPrompt']`, and reads `ctx.userInteraction` opportunistically at execute time (a type-only peer edge on `dsh-user-interaction`); its only UI-facing edges are optional type-only peers (`dsh-commands` for the per-definition entry commands). Beyond the `ctx.modes` call surface everything participates through listeners, so dropping the package gracefully removes modes rather than breaking a consumer. Terminal front doors need no mode-specific code: `dsh-mode` itself registers each definition's command on the command registry when one is composed (an optional type-only peer edge on `dsh-commands`), and the exit review rides the composed user-interaction provider's prompt queue. The ACP wire mapping is pinned in [High-level API](#over-acp); package-wise the bridge takes a type-only peer edge on `dsh-mode` and reads the service opportunistically, so a bridge without the plugin behaves exactly as today. - -### The recorded scenario and the harness op - -`input.json` gains one step op, `{ "op": "setMode", "modeId": "plan" }`, driven through the real `session/set_mode` RPC, and a scripted `elicitationAnswers` queue. The `plan-mode` scenario enters plan before turn 1, runs a real `cat` under the independently configured sandbox, presents a plan through `exit_plan_mode`, receives scripted approval, then edits on the next step. The first `request/header` contains the full stable toolset plus the configured mode section; the post-approval changed header retains byte-identical tool schemas and removes only that section. `plan-mode-reject` pins corrective free-text feedback and the unchanged plan state. Both recordings replay host commands under Seatbelt or bwrap; backend-specific sandbox denial stays at the bash-tool unit tier. - -### The mechanical tail - -No new cordis event is declared (`mode/set` rides `session/event`; the listeners attach to existing waterfalls), so the events catalog is untouched. Regenerated in the same change: the persistence log catalog (`mode/set`), the services catalog (`ctx.modes`, JSDoc-complete), the config catalog (`ModeConfig`), the tool catalog (`exit_plan_mode`), the producer/consumer map and doc graphs, and the module graph. Repo plumbing: a root tsconfig `paths` entry, the new group's README plus a [packages map](../../../../packages/README.md) row (a new top-level group is the deliberate act that table names), an `architecture.md` capability-services row for `ctx.modes` (budget-checked), and the cookbook row upgrade. - -## Deferred - -Each behind its own decision: subagent mode inheritance via a forwarded creation-time mode option (removed as unconsumed; it returns with its first consumer), preset modes beyond `plan` (read-only, accept-edits), the idle-record primitive if pending-intent loss proves real, and — the big one — **effects self-declaration on tool definitions**: a per-tool read-only/mutating classification (the MCP `ToolAnnotations` vocabulary — `readOnlyHint`/`destructiveHint` — is the natural template, with its untrusted-hint caveat implying trust tiers). That item is what a general per-mode tool policy waits on: this Agent Note first shipped an interim per-mode name allowlist and removed it before release — a hand-maintained list mislabels the effects question, must track every tool a deployment composes, and rots silently as tools arrive — so mode-scoped tool availability (and per-tool `ask` policies) returns as a CONSUMER of declared effects, which is its restart trigger. - -The ACP automation composition does not mount plan mode or the question tool. Human-facing compositions own plan selection and review; focused plan-mode tests and interactive-interface snapshots pin its logged state, guidance, review, and stable tool schemas. - -## FAQ - -Behavioral clarifications of the chosen design; rejected designs live in [Alternatives considered](#alternatives-considered), accepted costs in [Consequences](#consequences). - -**When does a user's mode flip take effect?** At the next pre-assembly boundary: `agent/prompt-submit` covers the first step, `agent/turn-continuation` covers a normal successor, and the post-composed `agent/request-error` retry decision covers automatic recovery. A mode selected while a request or retry backoff is in flight therefore shapes the following model request. This is the "applies to subsequent requests" semantics every product in [Prior art](#prior-art) ships. - -**When is a mode change narrated to the model?** Only when the model-visible state actually changed: the flush compares the flushed mode against the fold at the last `request/header` and narrates once, coalesced. A net-zero flip sequence (plan then back, all before the boundary) narrates nothing; a tool-driven exit narrates through its own tool result instead; a mode set before the first turn narrates nothing — the section is the state statement. The principle is the in-flight env-state proposal's boundary narration: a silently flipped prompt surface leaves the transcript arguing from a state the header no longer has. - -**What happens on resume when the config no longer defines the folded mode?** A folded mode name the current config no longer defines behaves as the default mode without a notice, so the session neither gains a substitute restriction nor becomes unusable. `set()`'s loud validation covers only the write path; a resumed log answers to the config it finds. - -**What if a deployment composes no user-interaction provider?** Plan mode stays safe but manual: `ctx.userInteraction.ask()` throws `NO_PROVIDER` (and an absent seam never resolves at all), the tool returns the corrective `isError`, and the exit degrades to the user toggling modes — never to an unreviewed exit. The mode section tells the model to present its plan through `exit_plan_mode` — and to ask the user in prose if that fails — so it keeps presenting instead of stalling. - -**Why is there no per-mode tool allowlist?** Because "which tools are safe in a planning mode" is a property of each TOOL (its effects), not of the mode — a per-mode name list re-declares that fact in the wrong home, must enumerate every tool the deployment composes (MCP servers included), and rots silently as tools arrive. Until tool definitions declare their effects ([Deferred](#deferred), where the removed interim allowlist is archived with its restart trigger), a mode restrains by its section and the exit review; the exposure is an accepted cost ([Consequences](#consequences)). - -**Do subagents inherit the parent's mode?** A fork child inherits for free — the parent's `mode/set` is inside the seeded prefix. A spawn child starts in the default mode; a creation-time mode option and automatic forwarding by subagent providers are deferred together ([Deferred](#deferred)). - -**How does plan mode relate to the sandbox's read-only mode?** They are separate axes that never touch: the mode is the collaboration stance (a `mode/set` fold), the sandbox mode is an enforcement knob (a `bash/sandbox-mode` fold, [the sandbox Agent Note](2026-07-06-sandbox.md)) — plan mode neither reads nor caps it, exactly as Codex keeps its Plan/Default presets separate from its sandbox and approval settings. A user who wants kernel-enforced read-only while planning sets both: flip the mode picker AND the sandbox-mode option, in either order; each switch changes only its own fold, so there is no interference and no restore step to crash out of. The log attributes each axis to its own event — the stance to `mode/set`, the confinement to `bash/sandbox-mode`. - -**Why aren't sandbox mode, approval policy, or the model themselves modes?** They are individual environment knobs independent of collaboration state. The retired ACP mapping is recorded by the [automation-only protocol decision](../simplification/2026-07-23-acp-automation-only-protocol.md). A mode definition may later bundle env facts (applied through `ctx.envState` where mounted) so a Codex-style preset stays a single mode; fusing approval policy into the mode CONCEPT itself is rejected in [Alternatives considered](#alternatives-considered). - -## Prior art - -A survey of shipped plan modes (Claude Code, Cursor, Copilot, OpenCode, Gemini CLI, Cline, Windsurf, Codex) shows the same five parts everywhere — the low-authority tool policy, plan artifact, approval moment, execution-state switch, and durable state that [Problem](#problem) builds on. - -The mode surface is a LIST everywhere it is advertised, never a boolean: Claude Code's picker offers `plan` beside `acceptEdits` (plus an auto-mode entry into plan), and Codex exposes `Plan` beside `Default` as collaboration-mode presets while keeping approval and sandbox settings separate. The ACP transport does not advertise this human-facing control. - -The deployment-owned example prompt borrows the instrumental behavior, not product-specific mechanics. From Codex: remain in plan mode despite imperative implementation language, explore before asking, distinguish repository facts from user-owned choices, and make the plan decision-complete across APIs, data flow, failures, tests, and assumptions. From Claude Code: prohibit mutations and commits, prefer existing patterns, use questions only for requirements or approach choices, and finish through the exit tool rather than a prose approval request. It deliberately omits Codex protocol tags and Claude's plan-file or phased-subagent machinery because those belong to their runtimes, not this plugin contract. - -The ecosystems that leave modes to convention show the failure shapes to avoid. Pi-style mode extensions fight over a last-wins global active-tool list, enforce "read-only" by prompt text alone (a hallucinated call to a still-registered tool executes), and re-inject plan state into every request to survive compaction. The contested global list and the re-injection hack close structurally here — per-agent folded state, and a log-only non-surface event compaction cannot shadow. The prompt-only shape, by contrast, is deliberately KEPT — it is what Codex ships for Plan, and it is why the mode axis composes freely with the enforcement axes: a deployment that wants a hard floor pairs the mode with the independent sandbox knob instead of the mode carrying its own enforcement ([FAQ](#faq)). - -## Alternatives considered - -**Permission modes as the concept (the Claude Code shape).** One `permissionMode` fusing approval policy and tool policy. Here those are two axes with two owners: the approval seam owns "who answers this question", modes own "what surface does the model get". ACP models them as related but distinct (a mode may select an approval policy later — a mode definition gains a field, not a merger). - -**A capability-seam trio.** Interface/implementation/consumer fits a swappable backend; a mode's variable parts are config values, not implementations. Splitting would manufacture an empty implementation package — the same "don't split preemptively" call the approval seam and [`todo/`](../../implemented/feature/2026-06-29-todo-write-tool.md) made. - -**Loop-owned mode state.** Rejected on the standing rule (plugins, not loop changes): every hook the feature needs — assemble, pre-execute, turn boundaries, session events — is already a documented seam, so a loop edit would buy nothing but coupling. - -**A per-mode tool allowlist with a deny-by-default gate (the first shipped shape).** Removed before release. A hand-maintained name list re-declares a per-TOOL fact (its effects) per MODE: it must enumerate every tool the deployment composes — MCP servers and future registrations included — and it rots silently as tools arrive (a new read-only tool is blocked until someone edits every mode; the author burden lands on whoever knows the mode, not whoever knows the tool). It also over-promises: the list looks like a security boundary while the real boundary for anything non-shell does not exist. The general dimension is parked on effects self-declaration ([Deferred](#deferred)); the consequence — plan mode is guidance-only, the very Pi hole the gate once closed — is accepted deliberately, priced in [Consequences](#consequences). - -**An `access` sandbox cap on the mode (the second shipped shape).** Also removed before release. `ModeDefinition.access` clamped the bash seam's per-call sandbox resolution to a mode-declared ceiling (a `bash/resolve-mode` waterfall + ladder-min listener, with guards withholding bash under an unconfinable executor and denying escalation mid-mode). The state stayed orthogonal — the clamp never wrote the sandbox knob — but the AXES did not: entering plan changed what the sandbox enforced, fusing the collaboration stance with an enforcement level and contradicting the Codex-shaped separation the review converged on (Plan/Default presets never touch sandbox or approval settings). One user-visible symptom of the fusion: flipping the sandbox option to `workspace-write` while planning silently did nothing. The cap, the waterfall, and the mode→bash dependency edge were removed together; a deployment gets kernel-enforced read-only planning by pairing the mode with the independent sandbox-mode option, and a mode-triggered PRESET (a mode definition bundling suggested knob values, applied as ordinary knob switches) can return later without re-fusing the axes. - -**Runtime-only mode (UI- or bridge-local, unlogged).** Resume and fork would silently drop the mode, and the header deltas a mode causes would have no attributable cause in the log. Logged state is what makes the mode auditable and restorable for free. - -**Mode flips as `context/message` via `agent.inject()`.** Reuses an existing turn-enclosure path, but puts policy state into the model transcript — the model does not need to be told twice (the section already tells it), and a log-only fact should not occupy surface. - -**A plan-file store (`.plans/` directory).** A second durable home for what the log already carries replayably; a deployment wanting files can add a tool that writes them. One home per fact. - -**A boolean `planMode` instead of named modes.** Too narrow for the surface the repo already tracks: ACP advertises a mode LIST and the shipped pickers fill it with more than plan ([Prior art](#prior-art)); generalizing later would rename durable event vocabulary. The string-shaped mechanism costs nothing extra now; only `plan` ships as a definition. - -**A tool-policy-stack service (the Pi-critique remedy).** A dedicated composition service for tool policies is premature: this implementation performs no mode-scoped tool filtering, and future effect policies can compose through the existing guarded execution seams. Formalize only when declared tool effects create a concrete composition requirement. - -**Exit approval through the approval seam (a `{ kind: 'ask' }` gate decision).** The original sketch, natural while the approval seam was the only asking machinery in flight — but it seats a review in a permission chair: the seam's outcome vocabulary is deliberately closed and one-shot (`allowed-once`/`rejected`), so a rejection carries no feedback and an approval can never grow options (approve-and-accept-edits). The exit moment is a question, not a permission — the user-interaction seam gives it options plus the free-text channel, and the rejection feedback reaches the model verbatim. The approval seam remains the right seat for genuine permission gates (the sandbox escalation), and the registry's `ask` vocabulary stays available to deployments that want one there. - -**Exit by prose or steering instead of a tool.** No artifact and no approval moment — the tool's argument IS the reviewable plan, and its review question is what gives the human a structured yes/no attached to the exact transition. - -## Consequences - -What holds now, pinned by the unit, protocol, snapshot, and real-API tiers: - -- The mode in force is a pure function of the session log: resume and fork restore it with no extra machinery, and a `mode/set` is followed by a matching complete `request/header` on the next changed step. -- A user-driven flip narrates exactly once at the next boundary and a net-zero flip sequence narrates nothing; a tool-driven exit narrates only through its tool result. -- In default mode the plugin contributes no mode section but does contribute the stable `exit_plan_mode` schema; a deployment without `dsh-mode` lacks that binding. -- Native tool schemas and Code Mode's SDK stay byte-identical across default, plan, and custom-mode transitions; only the configured guidance section changes. -- Plan mode changes nothing on the enforcement axes: the toolset, the sandbox mode, escalation, and the approval policy behave identically in plan and default — pairing the mode with the independent sandbox/approval knobs is how a deployment hardens planning. -- Mode definitions are changeable from `cordis.yml` with no code edit; the complete plan instructions are required there, while missing plan config, malformed definitions, and unknown keys fail at load and unknown mode names fail at `set()`. -- `exit_plan_mode` is always advertised, rejects outside plan, drops only plan guidance after approval, and carries keep-planning feedback in a corrective `isError`; each human-facing surface's user-interaction provider carries the review. -- The docs tail shipped with the landing: READMEs, regenerated catalogs (persistence log, config, cordis services, tools), the packages map and architecture rows, and the cookbook row. - -The accepted costs: a pending user flip set while idle is lost if the process dies before the next turn (the UI re-applies; the idle-record primitive is the escape hatch if this bites in practice). A mode transition changes the system prompt at order 50, so the cache path from that point onward changes, but the tool schemas and Code Mode SDK no longer churn. **A mode restrains by guidance alone**: a model that ignores the section CAN mutate during plan — the review moment, the session log, and independent sandbox, approval, and filesystem policies are the containment surface. Hardening planning means setting those knobs, not widening the mode; the removed enforcement shapes and their effects-declaration restart trigger remain in [Alternatives considered](#alternatives-considered) and [Deferred](#deferred). Human-facing interfaces own the plan picker and review interaction; the ACP automation transport carries neither. diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml deleted file mode 100644 index 28ecd2a765..0000000000 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-14-time-context-plugin.md: 189f75fc12fe12e9dec56fc71ea901ec2eaa8b19 -2026-07-14-time-context-plugin.zh.md: 12671cb891531627fffabb7bd91a1532bc3de6b9 diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md deleted file mode 100644 index 189f75fc12..0000000000 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.md +++ /dev/null @@ -1,59 +0,0 @@ -# Agent Note: Optional time-context plugin - -Status: implemented - -English | [中文](2026-07-14-time-context-plugin.zh.md) - -## Problem - -The dynamic system-prompt storage and refresh decision in this record is superseded by [Durable per-step time context](2026-07-16-durable-per-step-time-context.md). The opt-in package, zoned formatting, and validation remain; the follow-up owns the current model-visible and durability contract. - -An agent request has no live clock unless a deployment puts one in prompt text or gives the model a query tool. Static text becomes stale, while a tool call adds overhead to ordinary reasoning about dates, deadlines, or idle time. Without elapsed time, the model cannot distinguish an immediate follow-up from one sent hours after the preceding message. - -Prompt assembly can derive both facts per step from durable session timestamps, and request-header logging can record the exact rendered value. Accumulating stale readings in conversation history or waking idle agents would violate the existing request lifecycle. - -## Decision - -`@deepseek-ai/dsh-time-context` is an opt-in function plugin at `packages/context/time-context/`. The `context/` product group holds bounded request-context enrichments that define neither a tool nor a service. `dsh-agent-spine-demo` and shipped examples do not load the package; deployments mount it explicitly when its token and disclosure costs are acceptable. - -The plugin registers the global `context:time` system-prompt section at order 10, after the deployment persona and before tool guidance. For an active turn it emits an ISO-shaped timestamp with numeric UTC offset and IANA zone, plus a compact whole-second duration since the last model-visible message before the turn opened. Bare and idle assemblies receive an empty section. - -### Previous-message baseline - -At a turn's first assembly, the provider scans before `turn/start` for the latest `user/message`, `assistant/message`, `tool/result`, `context/message`, or `steering/message`. It excludes the current prompt so the duration expresses the inter-turn gap instead of approximately zero. Every refresh in that turn keeps the same baseline, and the first turn reports `unavailable (no earlier message in this session)`. - -The baseline is the session event's append time, not an unlogged client timestamp. Resume and fork behavior are therefore deterministic from the durable log, and the model-visible value remains reconstructable without a new event. A backward wall-clock adjustment clamps the duration to zero. - -### Refresh policy - -`refreshIntervalMs` defaults to 60,000 and must be a non-negative safe integer. Every turn's first request refreshes. Later assemblies in that turn reuse the block until its age reaches the interval; `0` refreshes every step. No timer creates work during model calls, tools, or idle time because refresh is request-bound. - -When `timeZone` is omitted, `Intl.DateTimeFormat` resolves the Node process's system zone once at plugin load. Node honors `TZ`; without that override, the host or container supplies the zone. An explicit value must be an IANA identifier and is validated at load. The captured zone remains stable until plugin reload, and the ISO-shaped local timestamp includes its current numeric offset so daylight-saving changes stay explicit. This is the deployment process's zone, not a remote user's zone. - -### Logging and token shape - -The loop records the temporal block in full `request/header` snapshots before transmission, satisfying the [reconstructable-requests contract](../architecture/2026-07-05-reconstructable-requests.md). Each request carries one current block; earlier readings do not remain in conversation history. The plugin owns the fact and contributes it through the prompt registry, following the [prompt-variables Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md) without a loop special case. - -## Testing - -Unit tests pin formatting, baselines, refresh policy, validation, per-agent state, disposal, and load-time system-zone capture. A real agent-loop test pins the transmitted prompt and full `request/header` snapshots. A keyless subprocess e2e boots a test-only `cordis.yml` through the real Loader and stdio app, omits `timeZone` under a controlled `TZ`, drives two turns, and verifies the persisted request headers externally. Default snapshot compositions omit the plugin, so their transcript fixtures contain no temporal block. - -## Alternatives considered - -- **Append a `context/message` on every turn or refresh** — rejected because readings and token cost would accumulate in history. Replacing a prior surface node would preserve its old position, while replacing the tail would hide intervening conversation. -- **Use `agent/session-prefix`** — rejected because the session-stable prefix cannot represent a per-turn or per-step clock. -- **Mutate requests in `agent/request`** — rejected because that seam shapes call config after the message boundary; inserted model content would bypass prompt-pressure accounting and request-header logging. -- **Register separate `{{current_time}}` and `{{elapsed}}` variables** — rejected because independent providers can sample different instants and require shared caching. One section records the pair atomically without a deployment-authored template. -- **Refresh from a background timer** — rejected because a new value has no consumer outside request assembly. Timer-driven `agent.inject()` would create turns and wake idle sessions merely to report time passing. -- **Keep UTC as the omitted default** — rejected because an explicitly enabled clock should follow its deployment environment unless the operator chooses UTC. `timeZone: UTC` remains available when a deployment requires it. -- **Add a time-zone detection library** — rejected because Node's `Intl` runtime already exposes the process's IANA zone. Another dependency cannot infer a remote user's zone either. -- **Mount the plugin in `dsh-agent-spine-demo`** — rejected because time zone, disclosure, token budget, and freshness are deployment policy. Opt-in keeps default context stable. -- **Place the package in `core/`** — rejected because `core/` owns the product API spine, while this plugin is an optional leaf with no service key. - -## Consequences - -- Opted-in models receive a zoned clock and inter-turn duration without a tool call. The system-prompt cost is fixed per request instead of growing with the session. -- An omitted `timeZone` follows the process's `TZ`, host, or container zone as observed at plugin load. Operators must configure an explicit zone when the deployment environment does not represent the intended user. -- A refresh changes the request header and can add a full `request/header` snapshot with reason `change`. `refreshIntervalMs` trades freshness against the number and size of durable full snapshots; `0` records a new value on every step whose whole-second rendering changes. -- No request exists solely to refresh time. A long-running tool leaves the prior reading until the next step assembles. -- Duration reflects harness processing time at durable append boundaries, not client-network latency before logging. Preserving a client-origin timestamp requires a separate durable input contract. diff --git a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md deleted file mode 100644 index 12671cb891..0000000000 --- a/.agents/notes/implemented/feature/2026-07-14-time-context-plugin.zh.md +++ /dev/null @@ -1,59 +0,0 @@ -# Agent Note:可选时间上下文插件 - -Status: implemented - -[English](2026-07-14-time-context-plugin.md) | 中文 - -## 问题 - -本记录中的动态系统提示词存储和刷新决策已由[持久的逐步骤时间上下文](2026-07-16-durable-per-step-time-context.md)取代。需要显式启用的包(package)、分区时间格式和校验仍然保留;后续 Agent Note 负责当前的模型可见与持久性契约。 - -如果部署方既未在提示词中提供时钟,也未给模型提供查询工具,agent(智能体)请求就无法获得实时准确的时间。静态文本会变得陈旧,而对于日期、截止时间或闲置时长等常规推理,调用工具会增加开销。缺少已经过去的时长时,模型无法区分紧接着发送的消息与上一条消息几小时后才发送的消息。 - -提示词组装流程可以在每个步骤中根据持久会话时间戳派生这两项信息,请求头日志则可以记录实际渲染的确切值。在会话历史中累积陈旧读数或唤醒空闲 agent 都会违反现有请求生命周期。 - -## 决策 - -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 产品分组用于容纳既不定义工具、也不定义服务的有界请求上下文增强。`dsh-agent-spine-demo` 和仓库提供的示例都不会加载该包;只有当 token 与信息披露成本可接受时,部署方才显式挂载它。 - -该插件注册顺序值为 10 的全局系统提示词区段 `context:time`,位置在部署方角色设定之后、工具指导之前。对于活跃轮次,它会输出带数字 UTC 偏移和 IANA 时区、形似 ISO 的时间戳,以及从轮次开始前最后一条模型可见消息起算的紧凑整秒时长。未绑定 agent 或 agent 处于空闲状态时,该区段为空。 - -### 上一条消息基线 - -在轮次首次组装时,提供方会在 `turn/start` 之前查找最近的 `user/message`、`assistant/message`、`tool/result`、`context/message` 或 `steering/message`。它会排除当前提示词,使时长表达轮次间隔,而不是接近零。同一轮次中的每次刷新都保留这条基线;首个轮次报告 `unavailable (no earlier message in this session)`。 - -基线采用会话事件的追加时间,而不是日志中不存在的客户端时间戳。因此,恢复和 fork 行为可以从持久日志中确定性重现,模型可见值也无需新增事件即可重建。系统挂钟向后调整时,插件会将时长钳制为零。 - -### 刷新策略 - -`refreshIntervalMs` 默认值为 60,000,并且必须是非负安全整数。每个轮次的首次请求都会刷新。同一轮次中的后续组装会复用该区块,直至其存在时间达到该间隔;设为 `0` 时每个步骤都刷新。刷新仅由请求驱动,因此在模型调用、工具运行或空闲期间,计时器不会创建任务。 - -省略 `timeZone` 时,`Intl.DateTimeFormat` 会在插件加载时解析一次 Node 进程的系统时区。Node 会遵循 `TZ`;没有该覆盖值时,时区由主机或容器提供。显式值必须是 IANA 标识符,并在加载时接受校验。捕获的时区在插件重新加载前保持稳定,形似 ISO 的本地时间戳包含其当前数字偏移,使夏令时变化保持显式可见。该默认值代表部署进程的时区,而不是远程用户的时区。 - -### 日志与 token 形态 - -agent loop(智能体循环)会在发送前通过完整的 `request/header` 快照记录时间区块,从而满足[可重建请求契约](../architecture/2026-07-05-reconstructable-requests.md)。每个请求只携带一个当前区块;先前的读数不会保留在会话历史中。该插件拥有时间信息,并按照[提示词变量 Agent Note](../architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)通过提示词注册表贡献该信息,无需为循环添加特殊分支。 - -## 测试 - -单元测试固定格式化、基线、刷新策略、校验、逐 agent 状态、资源释放行为,以及系统时区在加载时的捕获行为。使用真实 agent loop 的测试固定实际发送的提示词和完整的 `request/header` 快照。无密钥子进程端到端测试通过真实 Loader 和 stdio 应用启动测试专用 `cordis.yml`,在受控 `TZ` 下省略 `timeZone`,驱动两个轮次,并从外部校验持久请求头。默认快照组合不包含该插件,因此其中的 transcript(文本记录)fixture(测试前置数据)不包含时间区块。 - -## 考虑过的替代方案 - -- **每个轮次或每次刷新都追加一条 `context/message`**——不予采纳,因为读数和 token 成本会在历史中累积。替换先前的表层节点会保留其旧位置,而替换尾部节点会隐藏中间的会话内容。 -- **使用 `agent/session-prefix`**——不予采纳,因为会话期间保持稳定的前缀无法表示逐轮次或逐步骤变化的时钟。 -- **在 `agent/request` 中修改请求**——不予采纳,因为该边界在消息边界之后塑造调用配置;插入模型可见内容会绕过提示词压力核算和请求头日志。 -- **注册独立的 `{{current_time}}` 和 `{{elapsed}}` 变量**——不予采纳,因为独立提供方可能在不同时间点采样,并且需要共享缓存。单个区段会以原子方式记录两项信息,也不需要部署方编写时间模板。 -- **通过后台计时器刷新**——不予采纳,因为请求组装之外没有消费新值的对象。由计时器驱动 `agent.inject()` 会创建轮次,并且只为报告时间流逝就唤醒空闲会话。 -- **省略配置时仍默认使用 UTC**——不予采纳,因为显式启用的时钟应跟随部署环境,除非运维方选择 UTC。需要 UTC 的部署仍可配置 `timeZone: UTC`。 -- **引入时区探测库**——不予采纳,因为 Node 的 `Intl` 运行时已经能够提供进程的 IANA 时区,而且额外依赖同样无法推断远程用户的时区。 -- **在 `dsh-agent-spine-demo` 中挂载插件**——不予采纳,因为时区、信息披露、token 预算和新鲜度都属于部署策略。选择加入能保持默认上下文稳定。 -- **将包放入 `core/`**——不予采纳,因为 `core/` 负责产品 API 主干,而该插件是没有服务键的可选叶节点。 - -## 后果 - -- 选择加入的模型无需调用工具,即可获得分区时钟和轮次间隔时长。每个请求的系统提示词成本固定,不会随会话增长。 -- 省略 `timeZone` 时,插件采用加载时观察到的进程 `TZ`、主机或容器时区。当部署环境不能代表目标用户时,运维方必须显式配置时区。 -- 刷新会改变请求头,并可能新增一份 reason 为 `change` 的完整 `request/header` 快照。`refreshIntervalMs` 用新鲜度换取完整持久快照的数量与大小;设为 `0` 时,每个整秒渲染结果发生变化的步骤都会记录新值。 -- 系统不会仅为刷新时间而创建请求。长时间运行的工具会保留先前读数,直至下一步骤开始组装。 -- 时长反映持久追加边界处的 harness 处理时间,不包含消息进入日志之前的客户端网络延迟。若要保留客户端来源时间戳,需要单独的持久输入契约。 diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml index f037660761..910872881a 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.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-16-durable-per-step-time-context.md: 2d7076d51dbe1a64e5042230bddc6844141ff265 -2026-07-16-durable-per-step-time-context.zh.md: 432e0305cf44dcce1053c6580c9f0039309a7af4 +2026-07-16-durable-per-step-time-context.md: 4bc17b3c08707fcaa4f0f431e71ddbe567a03c9e +2026-07-16-durable-per-step-time-context.zh.md: 836c0f83fbe9d6120741a261cf25ce7d8c227bdf diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md index 2d7076d51d..4bc17b3c08 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.md @@ -12,13 +12,13 @@ A process-local refresh cache makes displayed time depend on state that cannot s ## Decision -`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `context/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. +`@deepseek-ai/dsh-time-context` is an opt-in function plugin in `packages/context/time-context/`. The `context/` group holds bounded request-context enrichments that define neither a tool nor a service, and shipped examples do not mount this plugin because its time-zone disclosure and token cost are deployment policy. It registers a prepended `agent/pre-step` listener and, when an injection is due, calls `agent.inject()` for a pre-step attempt whose signal is not already aborted. The injected `user/message` carries source `{ kind: 'plugin', plugin: 'time-context' }` and append surface metadata; a suppressed attempt appends nothing. The listener records preparation context before a possible `step/start`. Its prepended registration runs before ordinary automatic compaction listeners, so pressure estimation and any resulting surface rewrite observe a newly appended reading. A later pre-step listener can cancel or fail the attempt before the step opens; the reading remains because the durable log is append-only and this plugin performs no rollback. The optional `timeZone` config resolves the Node process's IANA zone once at plugin load when omitted; an explicit value is validated by `Intl.DateTimeFormat`. The timestamp includes the numeric UTC offset and resolved IANA zone. -The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `context/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. +The optional `refreshIntervalMs` config is manually validated at plugin load as a non-negative safe integer. Omission or `0` injects on every eligible preparation attempt. A positive value scans the raw session events for the most recent `user/message` with this plugin's source and injects when none exists, wall time moved backward, or the event is at least the configured age. The raw event timestamp governs even after compaction shadows the message, so scheduling persists across turns and process resume without a timer or process-local cache. ### Text and elapsed baselines @@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1: Elapsed since the preceding model-visible message: . ``` -The baseline is the latest preceding user, assistant, tool-result, context, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. +The baseline is the latest preceding user, assistant, tool-result, or steering message. This includes the accepted prompt that opened an ordinary message turn. If no model-visible message exists, the duration is `unavailable`. An injected later-step reading is: @@ -48,11 +48,7 @@ The plugin contributes nothing to system-prompt assembly. `request/header` conta ## Testing -Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader and stdio app, drives two turns, and verifies the persisted context events externally. - -## Supersedes - -This decision supersedes the dynamic system-prompt storage and refresh policy in [Optional time-context plugin](2026-07-14-time-context-plugin.md). It keeps the package location, opt-in deployment stance, timestamp formatting, process-zone default, and load-time validation. Durable history replaces the `context:time` prompt section, process-local refresh cache, and request-header deltas; `refreshIntervalMs` controls durable append frequency instead of prompt replacement. +Unit and real-loop tests pin formatting, both elapsed baselines, interval omission and zero, threshold boundaries, cross-turn and per-session scheduling, backward-clock behavior, invalid config, resumed raw-event lookup after compaction, aborted-signal behavior, later-listener cancellation and failure, listener disposal, source and surface metadata, cumulative multi-step visibility, and absence from request headers. A keyless subprocess e2e boots the real Loader with the Headless composition, drives two ordered one-shot turns, and verifies the persisted plugin-attributed messages externally. ## Alternatives considered @@ -61,10 +57,13 @@ This decision supersedes the dynamic system-prompt storage and refresh policy in - **Inject from a background timer** — rejected because idle time has no pending request to consume the value, and timer-driven injection would create durable turns solely to report time passing. - **Expose time only through a tool** — rejected because ordinary temporal reasoning would require an avoidable tool round trip and would not guarantee a reading before every step. - **Use `agent/session-prefix`** — rejected because one loop-instance prefix cannot represent distinct step timestamps and does not accumulate historically attributable readings. +- **Mutate assembled requests or register independent prompt variables** — rejected because request-local insertion bypasses the durable surface and separate providers can sample different instants. One attributed context message records the timestamp and elapsed baseline atomically. +- **Default to UTC or add a time-zone detection dependency** — rejected because an explicitly mounted plugin follows its process environment unless the operator selects an IANA zone, while no server-side library can infer a remote user's zone. +- **Mount the plugin in shipped compositions or place it in `core/`** — rejected because disclosure, time zone, freshness, and history cost are deployment choices for an optional context leaf, not product-spine policy. ## Consequences - Omission or `0` records every eligible preparation attempt; a positive interval reduces append frequency and history growth while preserving durable scheduling across resume. - Timing context remains append-only until compaction shadows older surface nodes, including a preparation reading left by a later cancellation or failure. - The first-step duration normally measures from the prompt that opened the turn, while later-step durations measure model and tool processing since the preceding step context. -- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. +- An omitted `timeZone` still reflects the deployment process rather than a remote user, and elapsed time still uses durable harness append boundaries rather than client-origin timestamps. Supporting client-origin time requires a separate durable input contract. diff --git a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md index 432e0305cf..836c0f83fb 100644 --- a/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md +++ b/.agents/notes/implemented/feature/2026-07-16-durable-per-step-time-context.zh.md @@ -12,13 +12,13 @@ Status: implemented ## 决策 -`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `context/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 +`@deepseek-ai/dsh-time-context` 是位于 `packages/context/time-context/`、需要显式启用的函数插件。`context/` 分组容纳有界的请求上下文增强,这些增强既不定义工具也不定义服务;已交付示例不挂载此插件,因为时区披露与 token 成本属于部署策略。它注册一个前置的 `agent/pre-step` 监听器,并在需要注入时,为信号尚未取消的预步骤尝试调用 `agent.inject()`。注入的 `user/message` 携带来源 `{ kind: 'plugin', plugin: 'time-context' }` 和追加表层元数据;受间隔抑制的尝试不会追加任何内容。 监听器在可能出现的 `step/start` 之前记录准备上下文。它采用前置注册,因此先于普通自动压缩监听器运行,使压力估算和由此产生的表层重写都能观察到新追加的读数。后续预步骤监听器可能在步骤开启前取消尝试或使其失败;持久日志仅追加,且本插件不执行回滚,因此该读数会保留下来。 省略可选配置 `timeZone` 时,插件在加载时解析一次 Node 进程的 IANA 时区;显式值由 `Intl.DateTimeFormat` 校验。时间戳包含数字 UTC 偏移和解析后的 IANA 时区。 -插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `context/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 +插件在加载时手动校验可选配置 `refreshIntervalMs`,其值必须为非负安全整数。省略或设为 `0` 时,每次符合条件的准备尝试都会注入。设为正数时,插件扫描原始会话事件,查找来源属于本插件的最新 `user/message`;不存在此类事件、系统挂钟向后移动,或该事件已达到配置时长时,插件执行注入。即使压缩已隐藏消息,调度仍以原始事件时间戳为准,因此该机制无需计时器或进程本地缓存,也能跨轮次和进程恢复持续生效。 ### 文本与时长基线 @@ -29,7 +29,7 @@ Time sampled while preparing turn , step 1: Elapsed since the preceding model-visible message: . ``` -基线是前一条用户消息、助手消息、工具结果、上下文消息或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 +基线是前一条用户消息、助手消息、工具结果或 steering(中途引导)消息。对于普通消息轮次,这包括开启轮次的已接受提示词。如果不存在模型可见消息,时长为 `unavailable`。 后续步骤的注入读数为: @@ -48,11 +48,7 @@ Elapsed since the preceding step context: . ## 测试 -单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试通过真实 Loader 和 stdio 应用启动,驱动两个轮次,并从外部校验持久化的上下文事件。 - -## 取代的决策 - -本决策取代[可选时间上下文插件](2026-07-14-time-context-plugin.md)中的动态系统提示词存储和刷新策略。它保留包位置、选择加入式部署、时间戳格式、进程时区默认值和加载时校验。持久历史取代 `context:time` 提示词区段、进程本地刷新缓存和请求头增量;`refreshIntervalMs` 用于控制持久追加频率,而非提示词替换。 +单元测试和真实 agent loop(智能体循环)测试固定格式化、两种时长基线、间隔省略和零值、阈值边界、跨轮次和各会话独立调度、挂钟后退行为、无效配置、压缩后基于恢复会话的原始事件查找、已取消信号行为、后续监听器取消和失败、监听器 dispose(资源释放)、来源与表层元数据、多步骤累计可见性,以及请求头中不存在时间上下文。无密钥子进程 e2e 测试使用 Headless 组合启动真实 Loader,依次驱动两个单次任务轮次,并从外部校验持久化且来源归属于插件的消息。 ## 考虑过的替代方案 @@ -61,10 +57,13 @@ Elapsed since the preceding step context: . - **通过后台计时器注入**——不予采纳,因为空闲期间没有待处理请求消费该值,而且计时器驱动的注入会仅为报告时间流逝而创建持久轮次。 - **只通过工具提供时间**——不予采纳,因为普通时间推理会产生本可避免的工具往返,也不能保证每个步骤之前都有读数。 - **使用 `agent/session-prefix`**——不予采纳,因为一个 loop 实例前缀无法表示不同的步骤时间戳,也不会累计具有历史归属的读数。 +- **修改已组装的请求或注册独立提示词变量**——不予采纳,因为请求内插入会绕过持久表层,不同提供方也可能在不同时间采样。一条带来源归属的上下文消息会原子地记录时间戳和时长基线。 +- **默认使用 UTC 或增加时区检测依赖**——不予采纳,因为显式挂载的插件默认遵循其进程环境,除非操作方选择 IANA 时区,而任何服务端库都无法推断远程用户的时区。 +- **在已交付组合中挂载插件,或把它放进 `core/`**——不予采纳,因为披露内容、时区、新鲜度和历史成本是可选上下文叶节点的部署选择,不是产品主干策略。 ## 后果 - 省略 `refreshIntervalMs` 或设为 `0` 时,每次符合条件的准备尝试都会留下记录;正数间隔会减少追加频率和历史增长,同时使持久调度在恢复后继续生效。 - 时间上下文仅追加并保留到压缩隐藏旧表层节点为止,其中也包括后续取消或失败所留下的准备读数。 - 第一个步骤的时长通常从开启轮次的提示词起算,后续步骤的时长则反映自上一条步骤上下文以来的模型与工具处理时间。 -- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。 +- 省略 `timeZone` 时仍采用部署进程而非远程用户的时区,时长仍采用 harness 的持久追加边界而非客户端来源时间戳。若要支持客户端来源的时间,需要另行建立持久输入契约。 diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml deleted file mode 100644 index 3ed957d231..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-20-tui-startup-slogans.md: a2a22baafddd08145cec0d03b65ee56b2f8114b1 -2026-07-20-tui-startup-slogans.zh.md: 58fa5790f315845f27b810d62658bd79428b519b diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md deleted file mode 100644 index a2a22baafd..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: Startup slogans replace the configured TUI welcome line - -Status: implemented - -English | [中文](2026-07-20-tui-startup-slogans.zh.md) - -> **Superseded** for the slogan/animation half by the [banner sweep Agent Note](2026-07-21-tui-banner-sweep.md): the slogan bank and typewriter reveal shipped, read as weird in use, and were replaced by a subtitle-free banner with a whole-banner sweep. The removal of the configured demo welcome and the animation-lifecycle groundwork (start after `ui.start()`, clear through `detachListeners`) stand. - -## Problem - -The TUI header subtitle came from a `welcome` config the demo leaf set to "TUI agent ready. Give it a coding task." — instructional filler that told a returning user nothing, restated what the product is on every boot, and had a hardcoded twin (`'ready.'`) as the schema default in two packages. The product wanted a startup moment with some character instead of a static banner caption. - -## Decision - -- `examples/tui-agent/cordis.yml` no longer configures `welcome`; the config key stays for deployments and fixtures that need a fixed, deterministic subtitle (the Code Mode overlay and every snapshot/scripted fixture keep theirs). -- When `welcome` is unset, `dsh-tui` picks one member of an exported `STARTUP_SLOGANS` bank per boot (`pickStartupSlogan`, injectable random source) and reveals it with a typewriter animation: one character per 40 ms frame, a `▌` block cursor trailing until complete. The reveal starts only after `ui.start()` succeeds and its interval is cleared on dispose alongside the other listeners. -- The slogan bank is presentation copy, deliberately not config: deployments that want controlled wording already have `welcome`. Slogans are ASCII-only by contract because the reveal slices per character. -- `dsh-tui-demo` forwards `welcome` only when configured instead of defaulting it, so the app no longer decides the TUI's idle subtitle. -- The keyless PTY boot scenario now waits for the reveal cursor (`▌` — the only source of that glyph in an empty transcript) instead of the removed welcome text. - -The same change restores `packages/ui/tui/src/index.ts` to 100 % per-file coverage, which the color-scheme merge had broken on the integration branch: the editor border-color reassignment inside `applyColorScheme` was dead (the `setStatus` call right after re-derives it) and is removed, and the color-scheme query's `.then`/`.catch` arrows became named, tested handlers (`applyReportedScheme`, `ignoreSchemeQueryFailure` — the latter pinned by a test whose terminal throws on the DSR query write). - -## Alternatives considered - -**A fixed cooler slogan.** Rejected: one string re-read on every boot decays into wallpaper exactly like the line it replaces; a small rotating bank keeps the moment alive at no complexity cost. - -**Making the bank and reveal speed configurable.** Rejected: that is two new knobs for presentation copy; `welcome` is already the escape hatch for deployments with an opinion, and the no-hardcoded-tunables rule targets deployment-varying behavior, not brand copy. - -**Animating in `HeaderComponent` itself.** Rejected: the component would need a TUI handle and its own lifecycle; the chat already owns a render loop, timers, and a disposal path, so the reveal lives beside the other `createTuiChat` effects and `detachListeners` clears it. - -## Consequences - -- Boot output is no longer byte-deterministic when `welcome` is unset (random slogan, timed frames). Every recorded or snapshot surface pins `welcome` explicitly, so no snapshot changed; the PTY smoke anchors on the reveal cursor and the session-id line instead. -- The `welcome` schema default disappeared from both `dsh-tui` and `dsh-tui-demo`; a direct caller passing no welcome now gets a slogan, not `'ready.'`. -- Adding a slogan is a one-line bank edit; tests assert membership, not specific text. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins deterministic bank selection with an injected random source, the reveal (a bank member fully rendered, cursor frames observed), the configured-welcome path rendering verbatim with no cursor, and dispose stopping a mid-reveal animation. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real tree in a PTY and waits on the reveal cursor. Verified live in tmux (mid-reveal frame `no map below▌` then the full slogan). diff --git a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md b/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md deleted file mode 100644 index 58fa5790f3..0000000000 --- a/.agents/notes/implemented/feature/2026-07-20-tui-startup-slogans.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 启动 slogan 取代配置化的 TUI 欢迎语 - -Status: implemented - -[English](2026-07-20-tui-startup-slogans.md) | 中文 - -> **已被取代**:slogan/动画的那一半由[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)取代:slogan 库和打字机动画上线后实际使用中显得怪异,已替换为无副标题的横幅加整体扫入。移除示例配置中欢迎语的决定与动画生命周期基础设施(`ui.start()` 后启动、经 `detachListeners` 清除)保持不变。 - -## Problem - -TUI 头部副标题来自一个 `welcome` 配置,示例叶子配置把它设为 "TUI agent ready. Give it a coding task."——一句说明书式的填充语,对老用户毫无信息量,每次启动都在复述产品是什么,而且它还有一个硬编码的孪生兄弟(`'ready.'`)作为两个包里的 schema 默认值。产品需要的是一个有性格的启动时刻,而不是一条静态横幅说明。 - -## Decision - -- `examples/tui-agent/cordis.yml` 不再配置 `welcome`;该配置键保留给需要固定、确定性副标题的部署与 fixture(Code Mode overlay 和所有快照/脚本化 fixture 都保留各自的欢迎语)。 -- `welcome` 未设置时,`dsh-tui` 每次启动从导出的 `STARTUP_SLOGANS` 库里挑选一条(`pickStartupSlogan`,随机源可注入),并以打字机动画逐字显示:每帧 40 ms 一个字符,完成前尾随一个 `▌` 块状光标。动画只在 `ui.start()` 成功后启动,其定时器与其他监听器一起在 dispose 时清除。 -- slogan 库是展示文案,刻意不做成配置:想控制措辞的部署已经有 `welcome` 这个出口。按契约 slogan 只含 ASCII,因为逐字显示按字符切片。 -- `dsh-tui-demo` 只在配置了 `welcome` 时才转发它,不再填默认值,应用不再替 TUI 决定空闲副标题。 -- 无 key 的 PTY 启动场景改为等待逐字显示的光标(`▌`——空 transcript 里该字形的唯一来源),不再等待已删除的欢迎文本。 - -同一变更把 `packages/ui/tui/src/index.ts` 恢复到 100% 的单文件覆盖率(颜色方案合并曾在集成分支上破坏它):`applyColorScheme` 里对编辑器边框颜色的重新赋值是死代码(紧随其后的 `setStatus` 调用会重新推导它),已删除;颜色方案查询的 `.then`/`.catch` 箭头函数改为具名、有测试的处理器(`applyReportedScheme`、`ignoreSchemeQueryFailure`——后者由一个让终端在 DSR 查询写入时抛错的测试固定)。 - -## Alternatives considered - -**换一条更酷的固定 slogan。** 否决:一条每次启动都重读的字符串会和它取代的那行一样退化成墙纸;一个小的轮换库以零复杂度代价让这个时刻保持新鲜。 - -**把 slogan 库和显示速度做成配置。** 否决:那是为展示文案新增两个旋钮;对措辞有主张的部署已经有 `welcome` 这个出口,而「插件里不许硬编码可调参数」规则针对的是随部署变化的行为,不是品牌文案。 - -**在 `HeaderComponent` 内部做动画。** 否决:组件将需要持有 TUI 句柄和自己的生命周期;聊天层已经拥有渲染循环、定时器和释放路径,所以逐字显示与 `createTuiChat` 的其他资源放在一起,由 `detachListeners` 清除。 - -## Consequences - -- `welcome` 未设置时启动输出不再字节级确定(随机 slogan、定时帧)。所有录制或快照表面都显式固定 `welcome`,因此没有快照变化;PTY 冒烟测试改为锚定逐字显示光标和会话 id 行。 -- `welcome` 的 schema 默认值从 `dsh-tui` 和 `dsh-tui-demo` 中消失;不传 welcome 的直接调用方现在得到的是 slogan,而不是 `'ready.'`。 -- 新增一条 slogan 只需在库里加一行;测试断言成员归属,不断言具体文本。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定以下行为:注入随机源后的确定性选取、逐字显示(库中某条完整渲染、观察到光标帧)、配置了 welcome 时逐字动画不启动且原文渲染、以及 dispose 停止进行中的动画。`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 里启动真实配置树并等待显示光标。已在 tmux 中实机验证(中途帧 `no map below▌`,随后是完整 slogan)。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml deleted file mode 100644 index 737a9da6ca..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-21-tui-auto-pane-title.md: 069fd33a8874d9ad3d4472dd13f5130b2df65f08 -2026-07-21-tui-auto-pane-title.zh.md: 580f36b2563e21231a22cab3f0c1689c6f3e8d9d diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md deleted file mode 100644 index 069fd33a88..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: Auto-titled terminal from the first message - -Status: implemented - -English | [中文](2026-07-21-tui-auto-pane-title.zh.md) - -> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. - -> **Superseded** for the default and the resume behavior by the [auto-title default-on Agent Note](2026-07-21-tui-auto-title-default-on.md): `autoTitle` now defaults on, and a resumed session re-derives its title from the stored first message instead of keeping the static one. The OSC 0 path, the one-shot latch, the model-summary shape, the fire-and-forget call, and every failure fallback below stand. - -## Problem - -The TUI's terminal title is a single static string (`title`, default `DeepSeek Harness`) shared by every session. A user who runs one agent per tmux pane or terminal tab sees the same label on all of them, so panes are indistinguishable at a glance and the tab bar carries no signal about what each session is doing. - -## Decision - -- `TuiConfig` gains an `autoTitle` boolean (default `false`). When it is on, the TUI issues one background model call after the first user message of a fresh session and replaces the terminal title with a short, model-generated label; the static `title` is the pre-title and the fallback. -- The label is a model summary, not a truncation of the prompt. The request carries a fixed task instruction (summarize the request as a short title of two to five lowercase words, no punctuation) plus the user's first message and no tools; the TUI takes the first non-empty line of the reply and caps it at 40 characters (39 plus an ellipsis). -- The title is set through `runtime.terminal.setTitle`, the same OSC 0 path the static `title` already uses. No new terminal-control surface is introduced, and pi-tui keeps ownership of terminal writes. -- The call is fire-and-forget and one-shot per session. A `titleSettled` latch guards it: with `autoTitle` off it is pre-settled and never runs; on a resumed session whose first `user/message` is already logged it is pre-settled so the static title stands; a whitespace-only first message is skipped without consuming the slot. Any failure, an empty reply, a missing `llm` service, or a missing agent provider/model leaves the static title untouched. A dedicated `AbortController` cancels an in-flight request on shutdown. -- The title call reaches `ctx.llm.stream` directly rather than through `agent.send`, so it never appends to the session or transcript and cannot perturb the agent loop. -- The feature defaults off and is enabled only in the interactive product config (`examples/tui-agent/cordis.yml`) and the scripted PTY fixture. Enabling it in the shared `dsh-tui-demo` schema default would fire an extra model call in keyless replay and boot scenarios that send no user message. - -## Alternatives considered - -**Truncate the first user message instead of a model title.** Rejected: the user chose a short model-made label; a truncated raw prompt is noisy, often begins with boilerplate, and rarely reads as a title. - -**Rename the window (OSC 2) or the tmux window.** Rejected: OSC 0 sets only `pane_title`, so it labels the pane without renaming or leaking into the user's window title; the user confirmed OSC is the right lever. - -**Default the feature on.** Rejected: enabling it in the shared demo schema perturbs keyless replay and boot snapshots and spends a model call on every fresh session; opt-in per deployment keeps the default surface inert. - -**Fold this into the log-backed session-title work (PR #451).** Rejected: that change is session metadata persisted to the log; this is a terminal label with no persistence. Keeping them independent leaves each self-contained and avoids a shared dependency. - -**Block the first turn until the title resolves.** Rejected: awaiting the title before sending the user's message adds latency to the actual request; fire-and-forget makes the rename invisible to the turn. - -## Consequences - -- When enabled, a fresh session spends one extra, tool-less model call with a single short user message and a few output tokens; off by default, it costs nothing. -- Because the title call stamps `sessionId`, it shares the session's `llm-replay` cursor: enabling `autoTitle` in a replay-backed snapshot scenario would consume a recorded script entry. This is why the default is off and the scripted PTY fixture answers the call with a tool-branching adapter rather than replay. -- `packages/ui/tui/tests/tui.spec.ts` pins the behavior with a mock `llm` adapter: a generated title replaces the static one, over-long output is truncated with an ellipsis, a whitespace-only first message keeps the one-shot slot, empty or failing replies leave the title, a resumed session never fires, and the feature-off / no-service / missing-provider / missing-model paths keep the static title. A shutdown test asserts the in-flight request is aborted. -- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` proves the real Loader-booted path: the scripted adapter answers the tool-less title call with a fixed string, and the conversation scenario asserts the OSC 0 sequence reaches the PTY. Boot scenarios send no user message, so they never fire the call. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md deleted file mode 100644 index 580f36b256..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-pane-title.zh.md +++ /dev/null @@ -1,41 +0,0 @@ -# Agent Note: 从首条消息自动命名终端 - -Status: implemented - -[English](2026-07-21-tui-auto-pane-title.md) | 中文 - -> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 - -> **已被取代**(就默认值与恢复行为而言),见[自动标题默认开启 Agent Note](2026-07-21-tui-auto-title-default-on.md):`autoTitle` 现默认开启,恢复会话会从已存储的首条消息重新推导标题,而非保留静态标题。下文的 OSC 0 路径、一次性门闩、模型概括形态、发出后不等待其返回的调用,以及每一条失败兜底,均仍然成立。 - -## Problem - -TUI 的终端标题是一个所有会话共用的静态字符串(`title`,默认 `DeepSeek Harness`)。在 tmux 每个窗格或每个终端标签页各跑一个 agent(智能体)的用户看来,它们的标签全都一样,因此窗格一眼看去无从区分,标签栏也不携带任何关于各会话正在做什么的信号。 - -## Decision - -- `TuiConfig` 新增布尔字段 `autoTitle`(默认 `false`)。开启后,TUI 会在全新会话的首条用户消息之后发起一次后台模型调用,并用一个简短的、模型生成的标签替换终端标题;静态 `title` 是替换前的初值,也是兜底。 -- 该标签是模型概括,而非对提示词的截断。请求携带一段固定的任务指令(将该请求概括为两到五个小写单词、不含标点的简短标题)加上用户的首条消息,且不带工具;TUI 取回复的首个非空行并截断到 40 个字符(39 个字符加一个省略号)。 -- 标题通过 `runtime.terminal.setTitle` 设置——静态 `title` 已经在用的同一条 OSC 0 路径。不引入任何新的终端控制面,终端写入仍归 pi-tui 所有。 -- 该调用发出后不等待其返回,且每会话仅一次。一个 `titleSettled` 门闩守护它:`autoTitle` 关闭时它预先置为已结算、从不运行;在首条 `user/message` 已入日志的恢复会话中它预先结算,因此静态标题得以保留;仅含空白的首条消息被跳过且不消耗名额。任何失败、空回复、缺少 `llm` 服务、或缺少 agent 的 `provider` 或 `model`,都会让静态标题保持不动。一个专用的 `AbortController` 在关闭时取消尚在进行的请求。 -- 标题调用直接抵达 `ctx.llm.stream`,而非经由 `agent.send`,因此它从不追加进会话或 transcript(文本记录),也无法扰动 agent loop(智能体循环)。 -- 该功能默认关闭,仅在交互式产品配置(`examples/tui-agent/cordis.yml`)与脚本化 PTY fixture(测试前置数据)中开启。若在共享的 `dsh-tui-demo` schema 默认值里开启,会在不发送任何用户消息的无密钥回放与启动场景中多发一次模型调用。 - -## Alternatives considered - -**截断首条用户消息,而非用模型生成标题。** 否决:用户选择的是简短的、模型制作的标签;截断后的原始提示词嘈杂、常以样板文字开头,且很少读起来像标题。 - -**重命名窗口(OSC 2)或 tmux 窗口。** 否决:OSC 0 只设置 `pane_title`,因此它标记窗格而不重命名、也不泄漏进用户的窗口标题;用户确认 OSC 是正确的手段。 - -**让该功能默认开启。** 否决:在共享的 demo schema 里开启会扰动无密钥回放与启动快照,并在每个全新会话上花掉一次模型调用;按部署选择性开启可让默认面保持惰性。 - -**并入日志支撑的会话标题工作(PR #451)。** 否决:那项改动是持久化到日志的会话元数据;本项是不做持久化的终端标签。让二者相互独立可使各自自成一体,并避免共享依赖。 - -**阻塞首轮直到标题就绪。** 否决:在发送用户消息前先等待标题,会给实际请求增加延迟;发出后不等待其返回可让重命名对该轮次不可见。 - -## Consequences - -- 开启时,全新会话会多花一次无工具的模型调用,只带单条简短的用户消息和少量输出 token;默认关闭时它不产生任何开销。 -- 由于标题调用会打上 `sessionId`,它与会话的 `llm-replay` 游标共享:在以回放支撑的快照场景中开启 `autoTitle` 会消耗一条录制脚本条目。这正是它默认关闭、且脚本化 PTY fixture 用按工具分支的适配器而非回放来回答该调用的原因。 -- `packages/ui/tui/tests/tui.spec.ts` 用一个 mock `llm` 适配器固定该行为:生成的标题替换静态标题、过长输出以省略号截断、仅含空白的首条消息保留一次性名额、空回复或失败回复保留标题、恢复的会话从不触发,以及功能关闭 / 无服务 / 缺提供方 / 缺模型各路径都保留静态标题。一项关闭测试断言尚在进行的请求被中止。 -- `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 证明真实的经 Loader 启动的路径:脚本化适配器以固定字符串回答无工具的标题调用,对话场景断言 OSC 0 序列抵达 PTY。启动场景不发送用户消息,因此它们从不触发该调用。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml deleted file mode 100644 index 830ca3e2e0..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-21-tui-auto-title-default-on.md: 35809e1ef6bade3e09c34b17608eff5f8fb5bd22 -2026-07-21-tui-auto-title-default-on.zh.md: aa20cfde1359605f2ac5a8f0427f4518c611ecd1 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md deleted file mode 100644 index 35809e1ef6..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: Auto-title on by default, re-derived on resume - -Status: implemented - -English | [中文](2026-07-21-tui-auto-title-default-on.zh.md) - -> **Superseded** by the [session-title consolidation Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md): the TUI-local `autoTitle` generation is removed; titles come from the log-backed session-title service, and the terminal rename consumes `session/title` events. - -## Problem - -The [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) shipped `autoTitle` off by default and, on a resumed session, kept the static title because the first `user/message` was already logged. In use both choices defeated the feature's purpose. A per-session descriptive pane title is what makes one tmux pane or terminal tab distinguishable from the next; leaving it off by default means the product ships an inert feature that almost no user turns on, and skipping re-derivation on resume means a resumed session — exactly the long-lived session most worth labelling — falls back to the shared static string. The user asked for a descriptive per-session name to be the normal experience. - -## Decision - -- `autoTitle` defaults **on** (`z.boolean().default(true)`, mirrored by `resolveTuiConfig`'s `?? true`). A deployment with an `llm` service and an agent provider/model gets a model-made pane title on every session without opting in; one without them keeps the static title, so default-on is inert where the call cannot run. -- A **resumed** session re-derives the title on mount from its already-logged first `user/message`: `createTuiChat` scans `agent.session.events` for the first such event and feeds its text to the same one-shot `generateTitle`. The title is never persisted (the session header carries no title field), so it is always derived, never restored. -- The one-shot latch is now simply `titleSettled = !resolved.autoTitle`. The prior pre-settle-on-resume clause is gone: on resume `generateTitle` runs once from the stored first message and then latches, so a message that arrives *after* the resume does not re-title. A fresh session has no stored `user/message` at mount, so the resume scan is a no-op and the live `session/event` listener titles the first message instead. -- Everything else from the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) stands unchanged: the OSC 0 `runtime.terminal.setTitle` path, the model-summary shape (two-to-five lowercase words, first non-empty line, 40-char cap), the fire-and-forget `ctx.llm.stream` call that never touches the session or transcript, the shutdown `AbortController`, and every failure fallback (empty reply, missing `llm`, missing provider/model, whitespace-only prompt). - -## Alternatives considered - -**Keep the feature off by default.** Rejected: this is a direct reversal of the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md)'s "default off" decision at the user's request. Off-by-default ships an inert feature; the descriptive name is only useful if it is the normal experience. The keyless-replay concern that motivated off-by-default is addressed by pinning `autoTitle: false` in the replay-backed snapshot scenarios rather than by suppressing it for every deployment. - -**Persist the derived title in the session header.** Rejected: the header has no title field and adding one would make a terminal label into session metadata — the boundary the [auto-title Agent Note](2026-07-21-tui-auto-pane-title.md) already drew against the log-backed session-title work. Re-deriving from the stored first message costs one tool-less call on resume and keeps the label a pure function of the conversation. - -**Re-derive on resume from the latest message instead of the first.** Rejected: the title summarises what the session is *about*, which its opening request captures; a mid-conversation message would make the pane label drift as the work moves on. - -## Consequences - -- A fresh session with a working `llm` now spends one extra tool-less model call by default (previously only when opted in); a resumed session spends one on mount. Deployments without an `llm` or provider/model are unaffected. -- The replay-backed `examples/tui-agent/tests/tui.snapshot.ts` must opt **out**: it pins `autoTitle: false`, because a default-on title request is not among the recorded turns and `installLlmReplay` fails loud on an unrecorded request. The unit `packages/ui/tui/tests/tui.snapshot.ts` needs no opt-out — it mounts no `llm` service, so `generateTitle` short-circuits and the default flip is inert there. The interactive `examples/tui-agent/cordis.yml` and the scripted PTY fixture already set `autoTitle: true`, so the keyless smoke's OSC 0 assertion is unchanged. -- `packages/ui/tui/tests/tui.spec.ts` pins the new defaults: the config-default test expects `autoTitle: true`; the disabled-path test now sets `autoTitle: false` explicitly; and the former "resumed session never fires" test is rewritten to assert re-derivation from the stored first message and that a later live message does not re-title. `docs/config-catalog.md` regenerates to "On by default". diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md deleted file mode 100644 index aa20cfde13..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-auto-title-default-on.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# Agent Note: 自动标题默认开启,恢复时重新推导 - -Status: implemented - -[English](2026-07-21-tui-auto-title-default-on.md) | 中文 - -> **已被取代**:见[标题归一 Agent Note](../simplification/2026-07-22-tui-titles-from-session-title-service.md)。TUI 本地的 `autoTitle` 生成已移除;标题来自日志承载的 session-title 服务,终端重命名消费 `session/title` 事件。 - -## Problem - -[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 交付时 `autoTitle` 默认关闭,并且在恢复会话中因首条 `user/message` 已入日志而保留静态标题。实际使用中这两个选择都违背了该功能的初衷。让一个 tmux 窗格或终端标签页区别于下一个的,正是每会话各异的描述性窗格标题;默认关闭意味着产品交付了一个几乎无人开启的惰性功能,而恢复时不重新推导,则意味着恢复会话——恰恰是最值得标记的长命会话——退回到共用的静态字符串。用户要求把每会话的描述性名称做成常态体验。 - -## Decision - -- `autoTitle` 默认**开启**(`z.boolean().default(true)`,`resolveTuiConfig` 以 `?? true` 与之对齐)。带有 `llm` 服务与 agent 提供方/模型的部署无需选择性开启即可在每个会话获得模型制作的窗格标题;不具备它们的部署保留静态标题,因此在调用无法运行处,默认开启是惰性的。 -- **恢复**会话在挂载时从其已入日志的首条 `user/message` 重新推导标题:`createTuiChat` 在 `agent.session.events` 中扫描首个此类事件,并把其文本喂给同一个一次性的 `generateTitle`。标题从不持久化(会话头不携带标题字段),因此它始终是推导得来,而非恢复而来。 -- 一次性门闩现在只是 `titleSettled = !resolved.autoTitle`。此前"恢复即预先结算"的分句已删除:恢复时 `generateTitle` 从已存储的首条消息运行一次随后上闩,因此恢复*之后*到达的消息不会再改标题。全新会话在挂载时没有已存储的 `user/message`,因此恢复扫描是空操作,改由实时的 `session/event` 监听器为首条消息命名。 -- [自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md) 的其余一切保持不变:OSC 0 的 `runtime.terminal.setTitle` 路径、模型概括形态(两到五个小写单词、首个非空行、40 字符上限)、从不触碰会话或 transcript(文本记录)的发出后不等待其返回的 `ctx.llm.stream` 调用、关闭时的 `AbortController`,以及每一条失败兜底(空回复、缺 `llm`、缺提供方/模型、仅含空白的提示词)。 - -## Alternatives considered - -**让该功能保持默认关闭。** 否决:这是应用户要求,对[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)"默认关闭"决策的直接反转。默认关闭交付的是惰性功能;只有当描述性名称成为常态体验时它才有用。当初促成默认关闭的无密钥回放顾虑,改由在以回放支撑的快照场景中固定 `autoTitle: false` 来处理,而非为每个部署都压制该功能。 - -**把推导出的标题持久化进会话头。** 否决:会话头没有标题字段,加一个会把终端标签变成会话元数据——正是[自动标题 Agent Note](2026-07-21-tui-auto-pane-title.md)已经对日志支撑的会话标题工作划出的边界。从已存储的首条消息重新推导,代价是恢复时一次无工具调用,并让标签保持为对话的纯函数。 - -**恢复时从最新消息而非首条消息重新推导。** 否决:标题概括的是会话*关于什么*,而这由其开场请求捕获;一条对话中途的消息会让窗格标签随工作推进而漂移。 - -## Consequences - -- 带可用 `llm` 的全新会话现在默认多花一次无工具的模型调用(此前只在选择性开启时才有);恢复会话在挂载时花掉一次。不具备 `llm` 或提供方/模型的部署不受影响。 -- 以回放支撑的 `examples/tui-agent/tests/tui.snapshot.ts` 必须选择**关闭**:它固定 `autoTitle: false`,因为默认开启的标题请求不在录制轮次之列,而 `installLlmReplay` 对未录制的请求会显式报错。单元 `packages/ui/tui/tests/tui.snapshot.ts` 无需选择关闭——它不挂载 `llm` 服务,因此 `generateTitle` 提前短路,默认值的翻转在那里是惰性的。交互式的 `examples/tui-agent/cordis.yml` 与脚本化 PTY fixture(测试前置数据)已设 `autoTitle: true`,因此无密钥冒烟测试的 OSC 0 断言保持不变。 -- `packages/ui/tui/tests/tui.spec.ts` 固定新的默认值:config 默认测试期望 `autoTitle: true`;关闭路径测试现在显式设 `autoTitle: false`;此前的"恢复会话从不触发"测试改写为断言从已存储首条消息重新推导,并断言之后的实时消息不会再改标题。`docs/config-catalog.md` 重新生成为"On by default"。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml deleted file mode 100644 index a06145f092..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-21-tui-banner-sweep.md: c146424d53e75a72b63e346f87a5bbd206d67350 -2026-07-21-tui-banner-sweep.zh.md: 01cc153e88f067b7b8d2eb6317648f3892fe8a5a diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md deleted file mode 100644 index c146424d53..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: The banner sweeps in; the subtitle line is gone - -Status: implemented - -English | [中文](2026-07-21-tui-banner-sweep.zh.md) - -> **Superseded** by the [no-banner Agent Note](2026-07-21-tui-no-banner.md): the banner itself was removed, taking the sweep with it. - -## Problem - -The [startup-slogans Agent Note](2026-07-20-tui-startup-slogans.md) replaced the instructional welcome line with a random slogan bank revealed by a per-character typewriter. In use the quotes read as weird — random flavor text in a tool's header — and the animation was slow (40 ms/char over a full sentence) while animating only one line of a four-line banner. This note supersedes that decision's slogan half; the removal of the configured demo welcome and the animation-lifecycle groundwork stand. - -## Decision - -- The slogan bank, `pickStartupSlogan`, and the typewriter reveal are deleted. When `welcome` is unset the banner simply has **no subtitle line** — title and model/session detail only. The `welcome` config remains for deployments and fixtures that want a fixed subtitle, rendered frame-deterministically with no animation. -- The startup animation is now the **whole banner**: `HeaderComponent` gains a `revealWidth` clip, and the header box wipes in left-to-right over ~24 frames at 15 ms (~360 ms total, ~60 fps), started after `ui.start()` succeeds and cleared through the same `detachListeners` path the typewriter used. `stopBannerReveal` also resets the clip so a disposed-mid-sweep header re-renders whole. -- The PTY smoke's boot marker changes from the typewriter cursor (`▌`) to the banner's top-right corner (`╮`), which only renders once the sweep completes. - -## Alternatives considered - -**Keep the animation as-is and only change the copy.** Rejected: any fixed or rotating phrase re-read on every boot decays into wallpaper; the user's judgment was that the quotes themselves, not just their content, were wrong for the surface. - -**Animate per banner line (top-down) instead of a left-right sweep.** Rejected: with only four lines the animation would have four visible steps — closer to a flicker than a reveal; the horizontal sweep uses the full terminal width for a smooth motion at the same total duration. - -**Character-level clipping via `revealWidth` on styled text.** Adopted with `truncateToWidth` from pi-tui, the same ANSI-aware clipper the header already uses for width overflow, so the sweep cannot tear escape sequences. - -## Consequences - -- Boot output with `welcome` unset is again animation-dependent but no longer random: every boot sweeps the same banner. Configured welcomes (all snapshot/scripted fixtures, the Code Mode overlay) stay frame-deterministic and unchanged. -- The `STARTUP_SLOGANS`/`pickStartupSlogan` exports are gone; no consumer outside the deleted tests referenced them. -- The default banner is one line shorter (no subtitle), so PTY assertions anchored on banner geometry use the corner glyph rather than any subtitle text. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins: the sweep completes to a full banner (both corners + title) and produced at least one clipped mid-sweep frame; a configured welcome renders verbatim with no clipped frames; the unset-welcome banner has no subtitle; and dispose clears the sweep's own interval handle. The PTY smoke boots on the `╮` completion marker across the tui-demo bin, the dsh CLI, and the personal-overlay scenarios. Verified live in tmux. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md deleted file mode 100644 index 01cc153e88..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-banner-sweep.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# Agent Note: 横幅整体扫入;副标题行移除 - -Status: implemented - -[English](2026-07-21-tui-banner-sweep.md) | 中文 - -> **已被取代**:由[移除启动横幅 Agent Note](2026-07-21-tui-no-banner.md)取代:横幅本身已移除,扫入动画随之移除。 - -## Problem - -[启动 slogan Agent Note](2026-07-20-tui-startup-slogans.md) 用随机 slogan 库加逐字打字机动画取代了说明书式的欢迎行。实际使用中这些引语显得怪异——工具头部出现随机的风味文案——而且动画很慢(每字符 40 ms,扫完一整句),却只动画四行横幅中的一行。本 note 取代该决定中 slogan 的那一半;移除示例配置中欢迎语的决定与动画生命周期的基础设施保持不变。 - -## Decision - -- 删除 slogan 库、`pickStartupSlogan` 和打字机动画。`welcome` 未设置时横幅直接**没有副标题行**——只有标题和模型/会话详情。`welcome` 配置保留给想要固定副标题的部署与 fixture,无动画、逐帧确定地渲染。 -- 启动动画现在作用于**整个横幅**:`HeaderComponent` 增加 `revealWidth` 裁剪,头部盒子以约 24 帧、每帧 15 ms(总计约 360 ms、约 60 fps)从左到右扫入,在 `ui.start()` 成功后启动,经打字机动画用过的同一条 `detachListeners` 路径清除。`stopBannerReveal` 同时重置裁剪,因此扫入中途被 dispose 的头部会重新完整渲染。 -- PTY 冒烟测试的启动标记从打字机光标(`▌`)改为横幅右上角(`╮`),它只在扫入完成后才渲染。 - -## Alternatives considered - -**保留动画原样、只改文案。** 否决:任何每次启动都被重读的固定或轮换语句都会退化成墙纸;用户的判断是引语本身——而不只是内容——对这个表面来说就是错的。 - -**按横幅行逐行(自上而下)动画而非左右扫入。** 否决:只有四行时动画只有四个可见步骤——更像闪烁而不是展开;水平扫入用满终端宽度,在相同总时长内动作更平滑。 - -**用 `revealWidth` 对带样式文本做字符级裁剪。** 采用 pi-tui 的 `truncateToWidth`——头部处理宽度溢出时已在使用的同一个 ANSI 感知裁剪器——因此扫入不可能撕裂转义序列。 - -## Consequences - -- `welcome` 未设置时启动输出再次依赖动画但不再随机:每次启动扫入同一幅横幅。配置了欢迎语的场景(全部快照/脚本化 fixture、Code Mode overlay)保持逐帧确定且不变。 -- `STARTUP_SLOGANS`/`pickStartupSlogan` 导出移除;除被删除的测试外没有消费者引用它们。 -- 默认横幅少一行(无副标题),因此锚定横幅几何的 PTY 断言使用角落字形而非任何副标题文本。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定:扫入完成为完整横幅(两个角 + 标题)且产生了至少一个裁剪的中途帧;配置的欢迎语原文渲染且无裁剪帧;未设置欢迎语的横幅没有副标题;dispose 清除扫入自己的定时器句柄。PTY 冒烟测试在 tui-demo bin、dsh CLI 和个人 overlay 场景中以 `╮` 完成标记启动。已在 tmux 中实机验证。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml index 8732101ab2..5d3ddbd972 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-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-borderless-banner.md: 37263854b6cc77283215c3c1378f9908ff966611 -2026-07-21-tui-borderless-banner.zh.md: ca796e49cb9d3a9abc0acd64a39448bc3f9ad50e +2026-07-21-tui-borderless-banner.md: 2fcb414c11f91df0914b17aa973e45746bbdfc67 +2026-07-21-tui-borderless-banner.zh.md: 8f80b21e6425bb38fff52529f1df8d262c34338f diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md index 37263854b6..2fcb414c11 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.md @@ -6,34 +6,41 @@ English | [中文](2026-07-21-tui-borderless-banner.zh.md) ## Problem -The [no-banner Agent Note](2026-07-21-tui-no-banner.md) removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion. +An intermediate no-banner design removed the boxed startup banner: it deleted `HeaderComponent` and its sweep, moved the model into the footer, dropped the session id, and rendered `welcome` as the transcript's first line. The user's verdict reversed that: bring the banner back — "just remove the border". The four-row box frame was the objectionable chrome, not the identifying facts it carried (model, session id) nor the sweep-in motion. ## Decision -- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. -- The header carries the title (`DEEPSEEK HARNESS`), a `` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only. -- The model **also** stays in the footer's left segment. The no-banner note's footer model prefix is kept, not reverted, so the driving model stays glanceable after the transient banner scrolls out of view. +- `HeaderComponent` and its left-to-right sweep return, but render **borderless**: no `╭─╮`/`╰─╯` corners and no `│` side bars. Each line is a single leading space plus `truncateToWidth`-clipped content, so the sweep's width clip can never tear an escape sequence and no fixed frame is drawn. The reveal advances through about 24 frames at 15 ms each. +- The header carries the title (`DEEPSEEK HARNESS`), a `` detail line, and — when `welcome` is set — a muted subtitle. With `welcome` unset the header is title + detail only: there is no fixed or random slogan. +- The model **also** stays in the footer's left segment, so the driving model remains glanceable after the transient banner scrolls out of view. - `welcome` reverts to a banner subtitle; the transcript-first-line notice is removed from `rebuildTranscript`. - The sweep animates only when `welcome` is unset. A configured `welcome` renders the whole banner immediately, keeping fixtures and snapshots frame-deterministic. The sweep starts after `ui.start()` succeeds and is cleared through the same `detachListeners` path via `stopBannerReveal`, which also resets the clip so a header disposed mid-sweep re-renders whole. -This supersedes the [no-banner Agent Note](2026-07-21-tui-no-banner.md) (which superseded the [banner-sweep Agent Note](2026-07-21-tui-banner-sweep.md)): the banner and its sweep return borderless, while the model's footer home the no-banner note added stays. +This note owns the current result of the discarded startup variants: random slogans with a per-character typewriter, a boxed whole-banner sweep, and no banner. The example composition does not set `welcome`; deployments and deterministic fixtures may still provide one. The model's persistent footer home from the no-banner variant remains. ## Alternatives considered **Keep the box but thin it or use lighter glyphs.** Rejected: the instruction was "just remove the border"; any surrounding glyph is the frame chrome the user objected to. -**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session — the reason the no-banner note put it there, deliberately preserved. +**Keep a random or fixed slogan when `welcome` is unset.** Rejected because repeated flavor copy becomes wallpaper and the per-character reveal was slow while animating only one line. An unset welcome therefore produces no subtitle, and the whole banner supplies the startup motion. -**Leave the session id out, as the no-banner note decided.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`. +**Remove the banner entirely.** Rejected because the persistent footer is a good home for the model but not for the full identifying detail, while putting `welcome` in the transcript makes presentation configuration behave like conversation content. + +**Reveal the banner top-down.** Rejected because four row-sized steps read as a flicker. The horizontal width clip uses the terminal span for smooth motion and reuses the ANSI-aware truncation path. + +**Drop the model from the footer now that the banner shows it again.** Rejected: the banner is transient and scrolls away with the transcript, while the footer keeps the model visible for the whole session; that persistent location is deliberately preserved. + +**Leave the session id out of the banner.** Rejected: with the box gone the detail line costs one row, and the user asked for the banner "as before", which carried `model • session-id`. ## Consequences - Boot output with `welcome` unset is animation-dependent again (the sweep); configured welcomes stay frame-deterministic, so every snapshot and scripted fixture keeps a fixed subtitle. +- The demo no longer supplies instructional welcome filler; an unset `welcome` means a subtitle-free banner, while the config remains the deterministic escape hatch for deployments and fixtures. - The model now appears twice at boot — banner detail and footer — intended redundancy: the banner is transient, the footer persistent. -- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike the no-banner welcome line that `/clear` wiped. +- `/clear` empties the transcript but not the header, so the banner and its configured subtitle survive `/clear`, unlike a transcript-based welcome line. - All pi-tui terminal snapshots and the examples/tui-agent replay snapshots re-recorded (`test:snapshot:refresh`): banner rows return with no box glyphs; footer rows keep the model prefix. - Anything that anchored on banner absence re-anchors on its presence: the PTY smoke boots on the detail line's `main-session-` id (revealed late in the sweep) and asserts `DEEPSEEK`/`HARNESS` present with no box corners. ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames. +`packages/ui/tui/tests/tui.spec.ts` pins: the borderless banner sweeps to natural completion — no box corners, title and `main-session` detail present — with at least one clipped mid-sweep frame; a configured `welcome` renders the whole banner with no clipped frame; the unset-welcome banner has no subtitle; and dispose clears the sweep interval mid-sweep. Independent color-scheme cases cover reported light/dark transitions, a same-scheme no-op, and a terminal that throws on the DSR query write; `applyColorScheme` relies on `setStatus` to rederive the editor border instead of repeating the dead assignment that had broken per-file coverage. The tui-agent and dsh-CLI PTY smokes boot on the `main-session-` detail marker and assert no box corners. Snapshots verify the full frames. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md index ca796e49cb..8f80b21e64 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-borderless-banner.zh.md @@ -6,34 +6,41 @@ Status: implemented ## Problem -[移除横幅 Agent Note](2026-07-21-tui-no-banner.md) 删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。 +一个中间的无横幅设计删掉了带框的启动横幅:它删除了 `HeaderComponent` 及其扫入动画,把模型移入页脚,丢弃了会话 id,并把 `welcome` 渲染为 transcript 的第一行。用户的裁决把这一切反转:把横幅拿回来——"just remove the border"。令人反感的装饰是那四行盒子边框,而不是它承载的识别信息(模型、会话 id),也不是扫入动效。 ## Decision -- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。 -- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情。 -- 模型**同时**保留在页脚的左段。移除横幅那版 note 加入的页脚模型前缀被保留而非回退,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。 +- `HeaderComponent` 及其从左到右的扫入动画回归,但以**无边框**方式渲染:没有 `╭─╮`/`╰─╯` 边角,也没有 `│` 侧边。每一行都是一个前导空格加上经 `truncateToWidth` 裁剪的内容,因此扫入的宽度裁剪永远不会撕裂转义序列,也不绘制任何固定边框。扫入大约经过 24 帧完成,每帧间隔 15 ms。 +- 头部承载标题(`DEEPSEEK HARNESS`)、一条 `` 详情行,以及——当设置了 `welcome` 时——一条弱化的副标题。`welcome` 未设置时头部只有标题加详情:不含固定或随机标语。 +- 模型**同时**保留在页脚的左段,因此在短暂的横幅滚出视野后,会话使用的模型仍可一瞥可见。 - `welcome` 恢复为横幅副标题;transcript 第一行的通知从 `rebuildTranscript` 中移除。 - 仅当 `welcome` 未设置时才播放扫入动画。配置了 `welcome` 会立即渲染整个横幅,使 fixture 和快照保持帧确定性。扫入在 `ui.start()` 成功后启动,并经与之前相同的 `detachListeners` 路径通过 `stopBannerReveal` 清理;后者还会重置裁剪,使扫入中途被销毁的头部重新完整渲染。 -本 note 取代[移除横幅 Agent Note](2026-07-21-tui-no-banner.md)(后者取代了[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md)):横幅及其扫入动画以无边框方式回归,而移除横幅那版 note 为模型设立的页脚归宿得以保留。 +本 Agent Note 统一记录几种已弃用启动方案的当前结论:带逐字打字机效果的随机标语、带边框的整幅横幅扫入动画,以及完全移除横幅。示例组装不设置 `welcome`;部署和确定性 fixture 仍可提供该值。无横幅方案为模型设置的常驻页脚位置继续保留。 ## Alternatives considered **保留盒子但做细或改用更轻的字符。** 否决:指令是 "just remove the border";任何环绕的字符都是用户所反对的边框装饰。 -**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见——这正是移除横幅那版 note 把它放在那里的原因,此处刻意保留。 +**在未设置 `welcome` 时保留随机或固定标语。** 否决:反复出现的氛围文案很快失去信息价值,而逐字揭示仅为一行制作动画,速度又慢。因此,未设置 `welcome` 时不显示副标题,由整个横幅提供启动动效。 -**像移除横幅那版 note 那样,把会话 id 留在外面。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。 +**完全移除横幅。** 否决:常驻页脚很适合显示模型,却无法承载完整识别详情;把 `welcome` 放入 transcript 还会使展示配置表现成对话内容。 + +**自上而下揭示横幅。** 否决:按四行分成四步看起来像闪烁。横向宽度裁剪利用终端横向空间实现平滑动效,并复用 ANSI 感知的截断路径。 + +**既然横幅重新显示模型,就把模型从页脚移除。** 否决:横幅是短暂的,会随 transcript 滚走,而页脚在整个会话中保持模型可见;这个常驻位置被刻意保留。 + +**将会话 id 留在横幅之外。** 否决:盒子去掉后详情行只占一行,且用户要求横幅"和以前一样",而以前它承载 `model • session-id`。 ## Consequences - `welcome` 未设置时的启动输出再次依赖动画(扫入);配置了欢迎语则保持帧确定性,因此每个快照和脚本 fixture 都保留一个固定副标题。 +- demo 不再提供教学性质的欢迎填充文案;`welcome` 未设置就表示横幅没有副标题,而该配置仍是部署和 fixture 获得确定性输出的配置手段。 - 模型现在在启动时出现两次——横幅详情与页脚——这是有意的冗余:横幅短暂,页脚常驻。 -- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于被 `/clear` 清掉的移除横幅那版的欢迎行。 +- `/clear` 清空 transcript 但不清头部,因此横幅及其配置的副标题在 `/clear` 后存活,不同于基于 transcript 的欢迎行。 - 全部 pi-tui 终端快照与 examples/tui-agent 回放快照重新录制(`test:snapshot:refresh`):横幅行以无盒子字符方式回归;页脚行保留模型前缀。 - 一切锚定横幅缺失的内容改为锚定其存在:PTY 冒烟测试以详情行的 `main-session-` id 为启动标记(它在扫入后段才被揭示),并断言 `DEEPSEEK`/`HARNESS` 出现且无盒子角。 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。 +`packages/ui/tui/tests/tui.spec.ts` 固定:无边框横幅扫入至自然完成——无盒子角、标题与 `main-session` 详情出现——且至少有一帧扫入中途被裁剪;配置的 `welcome` 完整渲染横幅且无裁剪帧;未设置 `welcome` 的横幅无副标题;销毁会在扫入中途清掉扫入定时器。独立的配色方案用例覆盖终端报告的浅色/深色转换、相同方案下的空操作,以及写入 DSR 查询时抛出异常的终端;`applyColorScheme` 依靠 `setStatus` 重新推导编辑器边框,而不再重复那个导致逐文件覆盖率未达标的无效赋值。tui-agent 与 dsh CLI 的 PTY 冒烟测试以 `main-session-` 详情标记为启动标记并断言无盒子角。快照验证完整帧。 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 deleted file mode 100644 index 56333563f5..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-21-tui-no-banner.md: f5f4b1b847740e741ec3e33a6116e7497e955bd1 -2026-07-21-tui-no-banner.zh.md: 956fe03e2c0b09ea7378ffd53ffbe8d712d1e152 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 deleted file mode 100644 index f5f4b1b847..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: No startup banner - -Status: implemented - -English | [中文](2026-07-21-tui-no-banner.zh.md) - -> **Superseded** by the [borderless-banner Agent Note](2026-07-21-tui-borderless-banner.md): the banner and its sweep return without the box. The model's footer home this note added stays. - -## Problem - -The TUI opened with a boxed product banner ("DEEPSEEK HARNESS" + model/session detail), most recently with a sweep-in animation ([banner sweep Agent Note](2026-07-21-tui-banner-sweep.md)). The user's verdict: remove it. A product title re-read on every boot is chrome, the box spends four rows before any content, and the identifying facts it carried (model, session) have better homes. - -## 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. -- `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. - -## Alternatives considered - -**Keep a one-line header (no box).** Rejected: the only load-bearing fact was the model name, and the footer already aggregates session status; a dedicated header row for one fact is the same chrome, smaller. - -**Show the session id in the footer too.** Rejected: a 36-char UUID dominates the 100-column footer and clips the status segment; it identifies the session for resume, which is a log/filesystem concern, not a glanceable one. - -**Print the welcome outside the transcript (above the separator).** Rejected: any fixed region above the transcript is a banner again; as a transcript line it scrolls away naturally and survives rebuilds through the same path as every other transcript element. - -## Consequences - -- Startup output is fully deterministic again — no animation frames at all; the interval-lifecycle machinery from the two animation iterations is gone. -- All 26 pi-tui terminal snapshots re-recorded (`test:snapshot:refresh`): banner rows gone, footer rows gain the model prefix. -- Anything that anchored on banner text (`DEEPSEEK`, box corners) re-anchors on the footer model name; `main-session-` no longer appears in boot output. -- `/clear` now wipes the welcome line too: it is an ordinary transcript line, and `/clear` empties the transcript (the old banner survived `/clear` only by sitting outside it). -- The footer's left segment is wider; on narrow terminals the right status segment clips earlier. - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` pins: no box corners/product title and an empty transcript when `welcome` is unset, with the model in the footer; a configured welcome as the first transcript line without a banner; and the welcome surviving a palette-swap transcript rebuild. The PTY smoke boots on the footer model name and asserts `DEEPSEEK HARNESS` is absent. Snapshots verify the full frames. 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 deleted file mode 100644 index 956fe03e2c..0000000000 --- a/.agents/notes/implemented/feature/2026-07-21-tui-no-banner.zh.md +++ /dev/null @@ -1,39 +0,0 @@ -# Agent Note: 移除启动横幅 - -Status: implemented - -[English](2026-07-21-tui-no-banner.md) | 中文 - -> **已被取代**,见[无边框横幅 Agent Note](2026-07-21-tui-borderless-banner.md):横幅及其扫入动画回归,只是去掉了盒子。本 note 为模型设立的页脚归宿得以保留。 - -## Problem - -TUI 启动时展示一个带框的产品横幅("DEEPSEEK HARNESS" + 模型/会话详情),最近一版还带扫入动画([横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md))。用户的裁决:删掉它。每次启动都被重读的产品标题是装饰,盒子在任何内容之前先占掉四行,而它承载的识别信息(模型、会话)有更好的去处。 - -## Decision - -- 删除 `HeaderComponent`、扫入动画及其生命周期接线。TUI 直接挂载进 transcript;启动时分隔线之上不渲染任何东西。 -- 模型名移入页脚状态行的左段(` ↑tokens ↓tokens`),会话使用的模型因此始终可见,而不只是启动时。会话 id 不再显示——它存在于会话日志和 `./.sessions` 文件名中,`RESUME_SESSION_ID` 的使用者从那里获取。 -- 配置了 `welcome` 时,它作为 transcript 的第一行(一条弱化的通知)在 `rebuildTranscript` 内渲染,因此调色板切换会保留它。未设置则什么也不渲染。fixture 保留各自配置的欢迎语;PTY 冒烟测试的启动标记改为页脚的模型名——无论 cwd 多长都保证渲染的唯一挂载后文本。 - -本 note 完全取代[横幅扫入 Agent Note](2026-07-21-tui-banner-sweep.md):扫入动画和它所动画的横幅都已移除。 - -## Alternatives considered - -**保留单行头部(去掉盒子)。** 否决:唯一有承载价值的信息是模型名,而页脚已经聚合会话状态;为一条信息保留专用头部行仍是同一种装饰,只是小一点。 - -**把会话 id 也放进页脚。** 否决:36 字符的 UUID 会占满 100 列页脚并裁掉状态段;它的用途是恢复会话的标识,属于日志/文件系统关注点,不是需要一瞥可见的信息。 - -**把欢迎语渲染在 transcript 之外(分隔线上方)。** 否决:transcript 上方任何固定区域都会再次变成横幅;作为 transcript 行它自然滚走,并通过与其他 transcript 元素相同的路径在重建后保留。 - -## Consequences - -- 启动输出再次完全确定——没有任何动画帧;两轮动画迭代留下的定时器生命周期机制全部移除。 -- 全部 26 个 pi-tui 终端快照重新录制(`test:snapshot:refresh`):横幅行消失,页脚行增加模型前缀。 -- 锚定横幅文本(`DEEPSEEK`、盒子角)的内容改为锚定页脚模型名;启动输出中不再出现 `main-session-`。 -- `/clear` 现在也会清掉欢迎行:它是普通的 transcript 行,而 `/clear` 清空 transcript(旧横幅能在 `/clear` 后存活只因为它在 transcript 之外)。 -- 页脚左段变宽;窄终端上右侧状态段更早被裁剪。 - -## Testing - -`packages/ui/tui/tests/tui.spec.ts` 固定:`welcome` 未设置时无盒子角/产品标题、transcript 为空、模型在页脚;配置的欢迎语作为 transcript 第一行且无横幅;欢迎语在调色板切换的 transcript 重建后保留。PTY 冒烟测试以页脚模型名为启动标记并断言 `DEEPSEEK HARNESS` 不出现。快照验证完整帧。 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml index 319f28ea61..8cac245f00 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.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-verbose-status-line.md: f277afd3a874b30a29dc0ef193740f636d22290b -2026-07-21-tui-verbose-status-line.zh.md: 9fa7cf29c67245382bbee6b72f2710c5550d7f54 +2026-07-21-tui-verbose-status-line.md: 71584ee91a911cc8652512ec26b00dae8c818f36 +2026-07-21-tui-verbose-status-line.zh.md: bda3c5e8394f7707916c6fc76045b1a6f38fa95b diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md index f277afd3a8..71584ee91a 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.md @@ -13,7 +13,7 @@ While a turn ran, the [full-screen TUI](2026-07-17-dedicated-full-screen-tui-fro - While a turn runs, the status line above the editor shows a derived phase label with elapsed time, keeping the trailing `— Enter sends steering, Esc cancels` hint. The four phases and their labels are `waiting` → "Waiting for the first token", `thinking` → "Thinking", `responding` → "Responding", and `executing` → "Executing tools". - The phase is presentation state the TUI derives from live session events, not a session event or agent status of its own. `step/start` enters `waiting`; an `assistant/chunk` reasoning delta or reasoning block-start enters `thinking`; a text delta or text block-start enters `responding`; a `tool/call` enters `executing`. The event map is merge-extensible, so every other event kind falls through a default and leaves the phase unchanged. - The label reports two clocks — ` · total ` — except `waiting`, which shows only the step total. The phase clock resets on a genuine phase change or a new step; the step clock resets on `step/start`. Durations format as `8s` below a minute and `1m05s` at or above one. Tool time between `step/end` and the next `step/start` accrues to the finishing step's total. -- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [banner sweep](2026-07-21-tui-banner-sweep.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`. +- A single `RunningStatus` controller — the loader, the phase, the two baselines, and a refresh timer — exists only while a turn runs. A one-second `setInterval` refreshes the elapsed time; a phase event refreshes it immediately. `clearStatus` clears the interval, stops the loader, and drops the controller, so any transition to idle or disposed leaves no live timer, matching the [borderless banner](2026-07-21-tui-borderless-banner.md)'s timer hygiene. A mid-turn palette rebuild (`setStatus` re-derives the editor border on a terminal color-scheme change) carries the phase and both baselines across, so a running status never snaps back to `waiting`. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md index 9fa7cf29c6..bda3c5e839 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-verbose-status-line.zh.md @@ -13,7 +13,7 @@ Status: implemented - 轮次运行期间,编辑器上方的状态行显示一个派生的阶段标签及已用时长,并保留末尾的 `— Enter sends steering, Esc cancels` 提示。四个阶段及其标签为 `waiting` → "Waiting for the first token"、`thinking` → "Thinking"、`responding` → "Responding"、`executing` → "Executing tools"。 - 阶段是 TUI 从实时会话事件派生出的呈现状态,而非它自有的会话事件或 agent 状态。`step/start` 进入 `waiting`;`assistant/chunk` 的 reasoning 分片或 reasoning 块开始(`block-start`)进入 `thinking`;text 分片或 text 块开始进入 `responding`;`tool/call` 进入 `executing`。该事件映射可合并扩展,因此其余任何事件类型都落入默认分支,保持阶段不变。 - 标签汇报两个时钟——` · total `——但 `waiting` 只显示步骤总时长。阶段时钟在真正发生阶段切换或进入新步骤时重置;步骤时钟在 `step/start` 时重置。时长在不足一分钟时格式化为 `8s`,达到或超过一分钟时格式化为 `1m05s`。`step/end` 与下一个 `step/start` 之间的工具时间计入结束步骤的总时长。 -- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与 [banner 扫入动画](2026-07-21-tui-banner-sweep.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。 +- 单一的 `RunningStatus` 控制器——loader、阶段、两个基准时刻以及一个刷新定时器——仅在轮次运行期间存在。一个每秒触发的 `setInterval` 刷新已用时长;阶段事件则立即刷新。`clearStatus` 清除该 interval、停止 loader 并丢弃控制器,因此任何向 idle 或 disposed 的转变都不会遗留活动定时器,与[无边框横幅](2026-07-21-tui-borderless-banner.md)的定时器清理保持一致。轮次进行中的调色板重建(终端颜色方案变化时 `setStatus` 会重新派生编辑器边框)会将阶段与两个基准时刻一并沿用过来,因此运行中的状态绝不会退回 `waiting`。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml deleted file mode 100644 index 9aa8a03c52..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-06-parallel-github-ci-gates.md: 5c276f6a75936021369bc5ad9494c9aa6e4e3fc3 -2026-07-06-parallel-github-ci-gates.zh.md: 7d98f842ef1d60a3a5b727f975cb1d93ea6f253c diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md deleted file mode 100644 index 5c276f6a75..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: Parallel GitHub CI gates - -Status: implemented - -English | [中文](2026-07-06-parallel-github-ci-gates.zh.md) - -## Problem - -The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every short leaf into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck. - -The original broad-lane split stopped meeting that balance as the workspace grew. On the merge of PR #404, Linux static, coverage, snapshot, and artifact jobs took 148, 195, 94, and 230 seconds; Windows static and artifacts took 251 and 482 seconds. Package-manager packing once per package dominated both artifact validators, coverage needlessly rebuilt output before a source-only suite, and CPU-heavy gates contended inside the static and coverage lanes. - -The artifact boundary remains load-bearing. `publint`, `verify-node-next-types`, compiled invariant loading, and built-bin smoke tests need emitted `lib/` output. Sharding cannot race those consumers ahead of build or replace their published-artifact signal with source execution. - -## Decision - -The production topology below is historical and is superseded by [Evidence-based larger hosted runners](2026-07-22-evidence-based-larger-hosted-runners.md). The larger-runner decision removes its shard selectors and workflow jobs; this note preserves why that earlier topology was implemented. - -[CI](../../../../.github/workflows/ci.yml) treats one minute for non-Windows jobs and three minutes for Windows jobs as observed performance targets, not cancellation deadlines. Hosted-runner variance should leave complete timing evidence and useful failure logs instead of cancelling an otherwise-correct gate. The [serial cross-platform CI reference](2026-07-21-serial-cross-platform-ci-reference.md) independently runs the complete unsharded primary Node aggregate on Linux, macOS, and Windows so the optimized lane inventory is not its own completeness oracle. - -In that topology, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) was the common bounded scheduler and GitHub supplied explicit shard names for the expensive gate families. `scripts/static-shards.ts` partitioned static gates into foundation, documentation-type, API-contract, catalog, prose, documentation-projection, and documentation-build ownership and rejected a missing or duplicate gate assignment. Linux lint used disjoint A-C, D-M, N-S, and T-Z package-source and package-test lanes, while Windows used complete package-source and package-test lanes; both included a repository complement starting from `.` so new top-level targets could not disappear between shards and owned the single cross-file duplication run. `scripts/coverage-shards.ts` assigned every workspace package to exactly one source-coverage lane. Directory filters retained a trailing separator because Vitest positional filters match substrings and would otherwise admit prefix-named siblings. Each coverage lane included only its owned source files, repeated the exhaustive companion topology test, and ran without a preceding build because the complete coverage suite passes from a tree with every generated `lib/` removed. - -Snapshot replay used two explicit multi-file lanes and eight scenario partitions of the large ACP file. `scripts/snapshot-shards.ts` owned that inventory, and its test discovered every file admitted by the snapshot config. Each snapshot job installed dependencies while its Linux runner prepared Bubblewrap, built the shipped runtime, and ran only its assigned replay surface. The suite retained bounded concurrency of five subprocesses because replay spent most of its time waiting on child protocol I/O. Fixture guards still inspected the complete ACP scenario table in every partition. - -Cold standalone documentation typechecking rebuilds the complete project-reference graph, so a dedicated documentation-type lane builds once and checks Markdown blocks against those declarations. The Linux documentation lane uses VitePress's MPA build to retain page rendering and dead-link validation within the observed non-Windows target; separate blocking Windows build and production-site lanes preserve the emitted-package and shipped-site checks without putting both critical paths in one job. - -Artifacts use two lanes: one metadata lane for `publint`, NodeNext declarations, and compiled invariant loading, plus one built-bin smoke lane. Each lane produces its own build before its consumers. Repeating the short build costs runner minutes but avoids an upload/download dependency and keeps each job's critical path bounded. - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) calls publint's supported API in-process against an in-memory publication view made from each manifest's declared files and npm's mandatory metadata files. This preserves the distinction between workspace files and published files without spawning a package-manager pack command 103 times. [scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) stages those structurally validated manifest-declared `lib/` files below the real package, then imports the compiled self-reference through plain Node and Cordis Loader normalization. A companion that reaches an undeclared runtime chunk still fails. - -Compatibility lanes run the source worker and Zstandard runtime smokes on every advertised Node line. TypeScript checks the source graph once in a dedicated primary Node 24 lane; repeating the same compiler analysis in runtime compatibility jobs added time without runtime-specific signal. - -The workflow caches the pnpm store, keys each immutable ESLint cache to its owning lint shard, preserves native PowerShell for Windows measurements, and retains one aggregate `all checks passed` status for branch protection. Windows reuses the three exhaustive lint partitions and groups foundation/catalog/prose plus documentation-type/API-contract gates behind shared runner setups; only scheduling differs from the Linux partitions. Windows build and production-site validation remain blocking, while the wider Windows static, lint, and artifact matrix remains observational. - -## Alternatives considered - -- **Keep the broad lanes** - minimizes workflow YAML, but it preserves the measured multi-minute feedback loop. -- **Run every leaf gate as a separate GitHub job** - maximizes fan-out, but short generators and prose checks would spend more time preparing a runner than checking the repository. -- **Upload one build to artifact consumers** - avoids repeated compilation, but upload/download and dependency scheduling lengthen wall time; the clean build is short enough to repeat inside bounded lanes. -- **Keep package-manager packing in both publication gates** - delegates inventory selection to pnpm, but repeats more than 200 package-manager processes. The manifest structural gate plus publication-view fixtures make the optimized inventory contract explicit and fail on an on-disk but unpublished dependency. -- **Keep build before coverage** - provides emitted output the source suite no longer consumes; a clean-tree coverage proof showed it was pure latency. -- **Typecheck on every Node version** - repeats compiler work while the compatibility smokes already exercise actual Node-specific loading and compression behavior. - -## Consequences - -The shard inventories and matrix jobs described above are not part of the current repository contract. The superseding larger-runner decision keeps the complete primary inventory in one process and uses the serial suite as its independent completeness oracle. - -The optimized publication validators rely on the manifest `files` contract enforced by `verify-package-invariants`. If publication rules grow beyond that contract, the structural gate and both staged views must change together. - -Compatibility jobs no longer claim that TypeScript itself was exercised under every Node runtime. They prove runtime-sensitive source loading on Node 22, 24, and 26, while the primary runtime owns the single source-graph typecheck. diff --git a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md b/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md deleted file mode 100644 index 7d98f842ef..0000000000 --- a/.agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 并行 GitHub CI 门禁 - -Status: implemented - -[English](2026-07-06-parallel-github-ci-gates.md) | 中文 - -## 问题 - -无密钥 GitHub CI 门禁大多相互正交:类型检查、lint、文档新鲜度、覆盖率、快照重放、构建、包(package)的发布卫生检查、demo 冒烟和已构建二进制冒烟会因不同原因失败,也不需要彼此的运行时状态。将它们作为一条有序命令链运行,会使工作流墙钟时间等于所有门禁耗时之和;而把每个短小叶子拆成独立 GitHub job,又会反复执行 checkout、Node 设置、pnpm 恢复和安装,直到编排开销成为瓶颈。 - -随着 workspace 增长,原有的宽车道拆分不再满足这一平衡。PR(Pull Request)#404 合并时,Linux 的静态、覆盖率、快照和产物 job 分别耗时 148、195、94 和 230 秒;Windows 的静态和产物 job 分别耗时 251 和 482 秒。每个包都调用一次包管理器打包,主导了两个产物验证器的耗时;覆盖率在仅运行源码的套件前无谓地重建输出;CPU 密集型门禁则在静态与覆盖率车道内争用资源。 - -产物边界仍然承载关键约束。`publint`、`verify-node-next-types`、已编译不变量加载和已构建二进制冒烟测试都需要生成的 `lib/` 输出。分片不能让这些消费方抢在构建前运行,也不能用源码执行取代它们对已发布产物的信号。 - -## 决策 - -下述生产拓扑已经成为历史,并由[基于证据采用更大的托管 runner](2026-07-22-evidence-based-larger-hosted-runners.md) 取代。更大 runner 的决策移除了其分片选择器和工作流 job;本文保留早期拓扑为何被实现的记录。 - -[CI](../../../../.github/workflows/ci.yml) 将非 Windows job 的一分钟和 Windows job 的三分钟视为观测所得的性能目标,而非取消截止时间。托管 runner 的波动应留下完整计时证据和有用的失败日志,而不是取消本来正确的门禁。[串行跨平台 CI 参考](2026-07-21-serial-cross-platform-ci-reference.md)会在 Linux、macOS 和 Windows 上独立运行完整、未分片的主 Node 聚合,使优化后的车道清单不会成为自身完整性的唯一判据。 - -在该拓扑中,[scripts/run-gates.ts](../../../../scripts/run-gates.ts) 是通用的有界调度器,GitHub 则为昂贵的门禁族提供显式分片名称。`scripts/static-shards.ts` 将静态门禁划分为基础、文档类型、API 契约、目录、正文、文档投影和文档构建等归属,并拒绝缺失或重复的门禁分配。Linux lint 使用互不重叠的 A-C、D-M、N-S、T-Z 包源码和包测试车道,Windows 则使用完整的包源码与包测试车道;两者都包含从 `.` 开始的仓库补集,使新增顶层目标无法消失在分片之间,并负责唯一一次跨文件重复检查。`scripts/coverage-shards.ts` 把每个 workspace 包恰好分配给一个源码覆盖率车道。目录过滤器保留尾部分隔符,因为 Vitest 位置过滤器按子字符串匹配,否则会纳入具有同名前缀的相邻项。每个覆盖率车道只包含其拥有的源码文件,重复运行穷尽式伴随拓扑测试,并且不先执行构建,因为从删除了所有生成式 `lib/` 的树开始,完整覆盖率套件仍可通过。 - -快照重放使用两个显式多文件车道,以及大型 ACP(Agent Client Protocol)文件的八个场景分区。`scripts/snapshot-shards.ts` 拥有该清单,其测试会发现快照配置允许的每个文件。每个快照 job 在其 Linux runner 准备 Bubblewrap 的同时安装依赖,随后构建已发布运行时,并且只运行分配给它的重放表面。该套件保留五个子进程的有界并发,因为重放的大部分时间都在等待子进程协议 I/O。fixture(测试前置数据)守卫仍会在每个分区中检查完整 ACP 场景表。 - -冷启动的独立文档类型检查会重建完整的项目引用图,因此专用文档类型车道只构建一次,再用这些声明检查 Markdown 块。Linux 文档车道使用 VitePress 的 MPA 构建,在观测所得的非 Windows 目标内保留页面渲染与死链接验证;单独的阻塞式 Windows 构建和生产站点车道保留已生成包与已发布站点检查,同时避免把两条关键路径放进同一个 job。 - -产物使用两个车道:一个元数据车道负责 `publint`、NodeNext 声明和已编译不变量加载,另一个负责已构建二进制冒烟。每个车道都会在其消费方之前自行构建。重复短时构建会消耗 runner 分钟数,但避免了上传/下载依赖,并使每个 job 的关键路径保持有界。 - -[scripts/publint-all.ts](../../../../scripts/publint-all.ts) 在进程内针对内存发布视图调用 publint 支持的 API;该视图由每份清单声明的文件和 npm 强制元数据文件构成。这样无需生成 103 次包管理器打包命令,也能保留 workspace 文件与已发布文件之间的区别。[scripts/verify-built-package-invariants.mjs](../../../../scripts/verify-built-package-invariants.mjs) 在真实包下暂存这些经过结构验证、由清单声明的 `lib/` 文件,再通过纯 Node 和 Cordis Loader 规范化导入已编译的自引用。若伴随项触及未声明的运行时分片,仍会失败。 - -兼容性车道会在每条声明支持的 Node 版本线上运行源码 worker 和 Zstandard 运行时冒烟。TypeScript 在专用的主 Node 24 车道中只检查一次源码图;在运行时兼容性 job 中重复同一编译器分析只会增加耗时,不会提供运行时特有信号。 - -工作流缓存 pnpm store,将每个不可变 ESLint 缓存的键绑定到其所属 lint 分片,为 Windows 测量保留原生 PowerShell,并保留一个聚合的 `all checks passed` 状态用于分支保护。Windows 复用三个穷尽式 lint 分区,并在共享 runner 设置后组合基础/目录/正文门禁与文档类型/API 契约门禁;只有调度方式与 Linux 分区不同。Windows 构建和生产站点验证继续阻塞,而更广泛的 Windows 静态、lint 和产物矩阵仍为观察性检查。 - -## 曾考虑的替代方案 - -- **保留宽车道**:最大限度减少工作流 YAML,但会保留观测到的数分钟反馈周期。 -- **让每个叶子门禁分别成为 GitHub job**:最大化扇出,但短小的生成器和正文检查准备 runner 的时间会超过检查仓库的时间。 -- **向产物消费方上传一次构建**:避免重复编译,但上传/下载和依赖调度会延长墙钟时间;干净构建足够短,可以在有界车道内重复。 -- **在两个发布门禁中保留包管理器打包**:把清单选择委托给 pnpm,但会重复启动 200 多个包管理器进程。清单结构门禁加发布视图 fixture 使优化后的清单契约显式化,并会在存在磁盘上有但未发布的依赖时失败。 -- **在覆盖率前保留构建**:提供源码套件已不再消费的生成输出;干净树覆盖率证明表明这只是纯粹的延迟。 -- **在每个 Node 版本上执行类型检查**:重复编译器工作,而兼容性冒烟已经验证实际的 Node 特有加载与压缩行为。 - -## 后果 - -上述分片清单和矩阵 job 不属于当前仓库契约。取而代之的更大 runner 决策在单个进程中保留完整主清单,并以串行套件作为独立完整性判据。 - -优化后的发布验证器依赖由 `verify-package-invariants` 强制执行的清单 `files` 契约。如果发布规则超出该契约,结构门禁和两个暂存视图必须一起变化。 - -兼容性 job 不再声称 TypeScript 本身已在每个 Node 运行时下执行。它们证明 Node 22、24 和 26 上对运行时敏感的源码加载,而主运行时负责唯一一次源码图类型检查。 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml index ae5ed9b11e..bd1468da18 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.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-19-require-agent-notes-for-non-trivial-changes.md: f2645832ebcdd0b81cbff5415c7eb6f60b6fa8cf -2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 659aa7cad0823fa0082be1827f8c083037376a4c +2026-07-19-require-agent-notes-for-non-trivial-changes.md: b9f631706437f380eb87422bdf7f4b8f83932a64 +2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 85265cd11e15575f07f14a34f68c6956b720fe67 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md index f2645832eb..b9f6317064 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md @@ -14,6 +14,8 @@ Every non-trivial change adds or updates at least one Agent Note in the same PR. Updating the note that already owns a decision satisfies the rule; a new note is required only when no note owns it. Purely mechanical or local edits with no behavioral, contractual, structural, process, or rationale change are exempt. The [Agent Notes README](../../README.md#when-to-write-one) owns this boundary, while root `AGENTS.md` carries the standing order. +A fully superseded implemented note may be consolidated into the current owning note and deleted only after that owner preserves every unique rationale, alternative, consequence, verification contract, and named coverage gap. The same change repairs inbound links and removes any Chinese counterpart, consistency record, and `required` entry in `scripts/translation-pairing.manifest.json`. Partial supersession keeps both notes cross-linked and current; consolidation neither rewrites an old decision into its opposite nor leaves git history as the only copy of rationale. + Review enforces the semantic boundary. No automated gate attempts to classify a diff as trivial or non-trivial, so this policy adds no gate stage or runtime. ## Alternatives considered @@ -22,10 +24,18 @@ Review enforces the semantic boundary. No automated gate attempts to classify a **Require a new note for every change.** This duplicates an existing note when it already owns the decision and adds empty ceremony to purely mechanical edits. +**Keep every fully superseded note indefinitely.** A cross-linked record is necessary while part of its decision remains current, but a wholly obsolete implemented note contradicts the current-state contract and duplicates rationale that can have one owner. + +**Add a `superseded/` lifecycle.** Another lifecycle would retain the obsolete record and expand the tree, format gate, and maintenance rules without reducing duplication. + +**Rewrite the old note into the replacement decision.** This erases the decision boundary and its rejected alternatives. Consolidation instead preserves those facts in the current owner before deleting the obsolete file. + **Add a CI diff-classification gate.** A mechanical check cannot reliably determine whether a semantic change is trivial, while another gate adds runtime and invites false positives or superficial compliance. ## Consequences - Every substantial change preserves its rationale and rejected alternatives beside the implementation. - Contributors maintain an existing owning note instead of creating duplicate records. +- Fully superseded records can collapse into one current owner without losing their unique rationale or verification contract. +- Partial supersession remains explicit and cross-linked, while deletion requires link, bilingual-pair, and required-manifest cleanup in the same change. - Mechanical edits remain lightweight, and the gate topology and runtime remain unchanged. diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md index 659aa7cad0..85265cd11e 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md @@ -14,6 +14,8 @@ Status: implemented 更新已经持有该决策的 Agent Note 即满足规则;仅当没有 Agent Note 持有该决策时才新增记录。完全机械或局部、且不改变行为、契约、结构、流程或决策依据的编辑可豁免。[Agent Notes README](../../README.md#when-to-write-one) 持有这条边界,根目录 `AGENTS.md` 则携带常驻指令。 +只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件、一致性记录,以及 `scripts/translation-pairing.manifest.json` 中对应的 `required` 条目。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。 + 评审负责执行这条语义边界。自动化门禁不尝试把差异分类为平凡或实质性变更,因此这项政策不会增加门禁阶段或运行时间。 ## 备选方案 @@ -22,10 +24,18 @@ Status: implemented **每项变更都必须新增 Agent Note。** 当现有 Agent Note 已经持有该决策时,这会产生重复记录,也会让纯机械编辑承担空洞的流程负担。 +**永久保留每份被完全取代的 Agent Note。** 只要旧决策仍有部分适用,就需要保留互相链接的记录;但完全失效的 implemented Agent Note 与记录当前状态的契约相矛盾,并重复保存本可由一个记录持有的决策依据。 + +**新增 `superseded/` 生命周期。** 新增生命周期仍会保留过时记录并扩张目录树、格式门禁和维护规则,却无法减少重复内容。 + +**将旧 Agent Note 改写为替代它的决策。** 这样会抹去决策边界及其否决的备选方案。合并做法是在删除过时文件前,先由当前持有决策的记录保存这些事实。 + **添加 CI 差异分类门禁。** 机械检查无法可靠判断语义变更是否平凡,额外门禁还会增加运行时间,并引入误报或表面合规。 ## 影响 - 每项实质性变更都会在实现旁保留其决策依据和被放弃的备选方案。 - 贡献者维护现有的决策持有记录,而不是创建重复记录。 +- 被完全取代的记录可以归并到一个当前持有记录中,同时不丢失其独有的决策依据或验证契约。 +- 仅部分被取代的情况仍需明确记录并互相链接;删除记录则必须在同一变更中清理链接、双语配对和 `scripts/translation-pairing.manifest.json` 的 `required` 条目。 - 机械编辑仍保持轻量,门禁拓扑和运行时间也保持不变。 diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml index 8bd4745529..46073fc52b 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.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-doc-sync-through-gate-scheduler.md: b7e41ba4aeac8ea03c706acadd481eee26abd5c2 -2026-07-21-doc-sync-through-gate-scheduler.zh.md: 56699747b1ba97fd90f7d53ab0deebc73ac775ef +2026-07-21-doc-sync-through-gate-scheduler.md: d66d9dc75ee4e8268d55e344a53c51c0bcf5f4d4 +2026-07-21-doc-sync-through-gate-scheduler.zh.md: 8c4c4595c2bc6e9439ed24cda1e70ae5a5ccd146 diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md index b7e41ba4ae..d66d9dc75e 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.md @@ -10,7 +10,7 @@ English | [中文](2026-07-21-doc-sync-through-gate-scheduler.zh.md) ## Decision -`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [parallel GitHub CI gates](2026-07-06-parallel-github-ci-gates.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. +`doc-sync` in `package.json` delegates to the existing bounded scheduler — `tsx scripts/run-gates.ts doc-sync` — like the `check:ci:*` scripts ([parallel gate scheduling](2026-07-06-parallel-pre-push-gates.md), [current CI topology](2026-07-22-evidence-based-larger-hosted-runners.md)). The `doc-sync` mode expands to exactly `docSyncLeafGates()`, making the leaf list in `run-gates.ts` the single source of truth for the member set. The local mode caps default concurrency at four workers because several doc gates each build a full `ts.Program`; `DSH_GATE_CONCURRENCY` still overrides. `docSyncLeafGates` includes `verify-cordis-api`, so relevant local documentation checks and CI gate the generated runtime API catalog alongside the other generated docs. diff --git a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md index 56699747b1..8c4c4595c2 100644 --- a/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-doc-sync-through-gate-scheduler.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[并行 GitHub CI 门禁](2026-07-06-parallel-github-ci-gates.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 +`package.json` 中的 `doc-sync` 委托给既有的有界调度器——`tsx scripts/run-gates.ts doc-sync`——与各 `check:ci:*` 脚本的做法一致([并行门禁调度](2026-07-06-parallel-pre-push-gates.md)、[当前 CI 拓扑](2026-07-22-evidence-based-larger-hosted-runners.md))。`doc-sync` 模式恰好展开为 `docSyncLeafGates()`,使 `run-gates.ts` 里的叶子列表成为成员集合的唯一真源。本地模式把默认并发上限设为四个 worker,因为多个文档门禁各自要构建完整的 `ts.Program`;`DSH_GATE_CONCURRENCY` 仍可覆盖。 `docSyncLeafGates` 包含 `verify-cordis-api`,因此相关的本地文档检查与 CI 会同其他生成文档一起把关生成的运行时 API 目录。 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml index 9d87cb9ad3..3d8e6fc395 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.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-22-evidence-based-larger-hosted-runners.md: aaeab4ed9ae9687598f9f1d4a862120405697672 -2026-07-22-evidence-based-larger-hosted-runners.zh.md: 72b69c85908990a9f35b60f4c0a2ce213f9c8134 +2026-07-22-evidence-based-larger-hosted-runners.md: fe11e6929545923d27fbf41f5a39f7dd2b9c3fbf +2026-07-22-evidence-based-larger-hosted-runners.zh.md: 47879284532a537cbe7e78aa2c495c4ef0be26c4 diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md index aaeab4ed9a..fe11e69295 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.md @@ -20,6 +20,10 @@ The former gate-level and coarse primary shard jobs are absent from the workflow Linux primary work uses three independent 32-core jobs. Coverage runs alone with its own worker bound, and the static scheduler runs alone so its result has no post-build consumer tail. After static gates finish, that job publishes its emitted `apps/*/lib`, `packages/*/*/lib`, and `vendor/*/lib` tree as a run-scoped artifact. The third job restores that exact tree, then starts lint, Node 24 runtime compatibility, build-backed snapshots, and all artifact consumers without repeating the build. Generated NodeNext consumer directories are excluded from ESLint discovery because the artifact check removes them while these processes overlap. The pnpm store and ESLint cache are restored without putting cache uploads on the pull-request critical path. Performance reports use each job's `startedAt` to `completedAt` interval; runner queue delay is capacity evidence, not repository execution time. +The gate dependencies remain explicit. Coverage consumes source and does not wait for build. Documentation typechecking builds its complete project-reference graph once. Snapshot replay and publication consumers wait for emitted output, while Node-version compatibility jobs exercise runtime-sensitive source loading without repeating the primary source-graph typecheck. PTY and subprocess suites keep their bounded inner concurrency rather than inheriting the runner's core count. + +The artifact boundary remains explicit. `scripts/publint-all.ts` calls publint's supported API against an in-memory publication view formed from each manifest's declared files plus npm's mandatory metadata, avoiding one package-manager pack process per package. `scripts/verify-built-package-invariants.mjs` stages the declared `lib/` files below the real package and imports its compiled self-reference through plain Node and Cordis Loader normalization; a runtime chunk omitted from the publication contract still fails. + Windows shares one 32-core setup across the blocking build and production site plus observational built-artifact contracts. Linux owns the duplicate lint, coverage, and snapshot inventories because running those observational copies on Windows extends the paid critical path without adding a blocking platform claim. An [exact-head all-size benchmark](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351) ran the complete unsharded primary Node aggregate on every Linux pool before the eager-build correction: @@ -54,6 +58,10 @@ Complete serial Linux, macOS, and Windows references run only when `master` move **Keep the former gate-level shard topology as a manual reference.** A dormant second topology kept hundreds of workflow lines, selector modules, and scenario-partition behavior alive. The all-size and serial suites provide timing and completeness controls without preserving production code that no required job exercises. +**Return to package-manager packing in each publication validator.** Rejected because it repeats a package-manager subprocess for every package. The manifest-derived publication view and staged compiled self-reference preserve the published-file contract with one in-process inventory. + +**Build before coverage or typecheck on every Node version.** Rejected because coverage is source-only and compiler analysis is not runtime-specific. Build-backed consumers still wait for emitted output, and compatibility jobs exercise the runtime-sensitive paths on every advertised Node line. + **Use the 64-core pool for the complete primary aggregate.** Its sampled active time was three seconds lower than the 96-core result because hosted setup was nine seconds faster, but its repository gates were 5.72 seconds slower. The benchmark suite retains both pools because a sustained image or pricing change can reverse the comparison. **Keep build behind typecheck.** This orders independent compiler invocations and turns snapshot replay into a three-stage critical chain. Build output has its own success dependency, so only snapshot and publication consumers wait for it. diff --git a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md index 72b69c8590..4787928453 100644 --- a/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md +++ b/.agents/notes/implemented/process/2026-07-22-evidence-based-larger-hosted-runners.zh.md @@ -20,6 +20,10 @@ Status: implemented Linux 主流程使用 3 个相互独立的 32 核作业。覆盖率单独运行,并设有自己的工作线程上限;静态调度器也单独运行,因此构建后的消费方不会拖延其结果。静态门禁完成后,该作业将其生成的 `apps/*/lib`、`packages/*/*/lib` 和 `vendor/*/lib` 目录树作为仅供本次运行使用的产物发布。第三个作业恢复完全相同的目录树,再让 lint、Node 24 运行时兼容性、依赖构建产物的快照和所有产物消费方基于构建完成后的工作树启动,而不重复构建。生成的 NodeNext 消费方目录不会纳入 ESLint 的文件发现范围,因为这些进程重叠执行时,产物检查会删除这些目录。pnpm store 和 ESLint 缓存会得到恢复,但缓存上传不会进入拉取请求关键路径。性能报告采用每个作业从 `startedAt` 到 `completedAt` 的区间;运行器排队延迟是容量证据,而非仓库执行时间。 +门禁依赖关系保持显式。覆盖率消费源码,不等待构建。文档类型检查只构建一次完整的 project-reference 图。快照回放和发布消费方等待生成的输出,而 Node 版本兼容性作业会验证对运行时敏感的源码加载,且不重复主源码项目图的类型检查。PTY 和子进程套件继续使用自身有界的内部并发,不继承运行器的核心数。 + +产物边界保持显式。`scripts/publint-all.ts` 对内存中的发布视图调用 publint 支持的 API;该视图由每个 manifest(元数据清单)声明的文件和 npm 强制要求的元数据组成,从而避免为每个包启动一次包管理器 pack 进程。`scripts/verify-built-package-invariants.mjs` 将已声明的 `lib/` 文件暂存到真实包下,并通过普通 Node 和 Cordis Loader 规范化导入其已编译的自身引用;发布契约只要遗漏一个运行时分片,检查仍会失败。 + Windows 以一次 32 核环境设置同时承载阻塞性构建、生产网站和观测性的构建产物契约。重复的 lint、覆盖率和快照清单由 Linux 承担,因为在 Windows 上运行这些观测性副本会延长付费关键路径,却不会新增任何阻塞性平台契约。 一次[分支头精确的全规格基准测试](https://github.com/deepseek-harness/deepseek-harness/actions/runs/29908491351)在修正构建尽早启动逻辑前,对每种 Linux 池都运行了完整且未分片的主 Node 聚合流程: @@ -54,6 +58,10 @@ Windows 仓库工作在超过 16 核后收益很小,但 32 核池可以让完 **将原有的门禁级分片拓扑保留为手动参考。** 一套闲置的第二拓扑会让数百行工作流、选择器模块和场景分区行为继续存活。全规格和串行套件无需保留任何必需作业都不执行的生产代码,也能提供计时与完整性对照。 +**在每个发布校验器中恢复使用包管理器打包。** 不予采用,因为这会为每个包重复启动一个包管理器子进程。根据 manifest 构建的发布视图和已暂存的编译后自身引用,只需一份进程内清单即可保留发布文件契约。 + +**在每个 Node 版本上先构建,再运行覆盖率或类型检查。** 不予采用,因为覆盖率只消费源码,编译器分析也不依赖运行时。依赖构建产物的消费方仍等待生成的输出,兼容性作业则在每个已声明支持的 Node 版本上验证对运行时敏感的路径。 + **使用 64 核池运行完整主聚合流程。** 由于托管设置快了 9 秒,其采样活动耗时比 96 核结果少 3 秒,但仓库门禁慢了 5.72 秒。基准测试套件保留两种规格,因为映像或定价的持续变化可能反转比较结果。 **让构建继续等待类型检查。** 此方案会给相互独立的编译器调用排定先后顺序,并把快照回放变成 3 阶段关键链。构建输出本身有独立的成功依赖关系,因此只有快照和发布消费方需要等待它。 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml deleted file mode 100644 index 12abdeaada..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-04-fold-stdio-ui-helper.md: b9c4c6cfb7643890a7cf4dcdeb9014d7c7158818 -2026-07-04-fold-stdio-ui-helper.zh.md: d71fc878c603757f7da20aab3e7e219e368e7745 diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md deleted file mode 100644 index b9c4c6cfb7..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: Fold the stdio UI helper into the stdio app - -Status: implemented - -English | [中文](2026-07-04-fold-stdio-ui-helper.zh.md) - -The later [redundant-agent removal](2026-07-20-remove-stdio-and-echo-agents.md) supersedes this package-placement decision and removes the folded package, app, and line-oriented surface entirely. - -## Problem - -The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-demo`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. - -The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. - -## Decision - -At the time, the helper moved into `@deepseek-ai/dsh-stdio` as the terminal-channel plugin. `createStdioChat`, its `StdioRuntime` test seam, and its unit tests moved with it, keeping EOF handling, rendering, disposal, and piped-vs-TTY behavior under the per-file coverage gate without hijacking process globals. The module kept the named `name`/`inject`/`Config`/`apply` export shape consumed by the app mount, while the then-current Echo and REPL Loader smokes proved the composed tree and the plugin-shape suite pinned explicit `unwrapExports` behavior. The superseding removal note above owns the current package and example state. - -The earlier support helper package was removed: its manifest, tsconfig references, module-graph rows, and README rows disappeared, while the remaining documentation described the in-package module. - -## Alternatives considered - -### Why not promote it to `ui/` instead? - -Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is an automation protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract. - -## Consequences - -- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos. -- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse. diff --git a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md b/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md deleted file mode 100644 index d71fc878c6..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# Agent Note: 将 stdio UI 辅助模块折入 stdio 应用 - -Status: implemented - -[English](2026-07-04-fold-stdio-ui-helper.md) | 中文 - -后来的[冗余 agent(智能体)移除](2026-07-20-remove-stdio-and-echo-agents.md)取代了这项包放置决策,并完整移除合并后的包、应用和面向行的表面。 - -## 问题 - -readline UI 曾是一个完整的包(`packages/support/` 下的 `@deepseek-ai/dsh-ui-stdio`),其唯一的运行时导入方是应用包 `@deepseek-ai/dsh-stdio-demo`。示例通过加载应用来使用 readline UI,从不自行组合该辅助模块;仓库中所有其他引用都是因为包边界存在而存在的机械性或描述性表面:manifest(元数据清单)与 tsconfig 条目、生成的 module-graph 行、依赖图与 README 行,以及命名该包的文档注释。ui 组 README 记录了 support 放置的理由("主要为示例和覆盖率门禁而存在,`ui/` 保留给作为产品交付的界面"),这留下了一个持续的张力:一个已交付的产品应用依赖一个被明确标注为非产品表面的 support 包。 - -这条边界换来的是:包元数据、workspace 与 tsconfig 引用、module-graph 行、README 条目,以及 publint 表面——服务于一个并不可独立替换的辅助模块:stdio 应用的前门集群始终包含 readline UI,且没有其他消费方能有意义地使用它。 - -## 决策 - -当时,该辅助函数移入 `@deepseek-ai/dsh-stdio`,成为终端通道插件。`createStdioChat`、其 `StdioRuntime` 测试 seam 和单元测试随之一同迁移,使 EOF 处理、渲染、释放以及管道/TTY 行为继续受逐文件覆盖率门禁约束,而不会劫持进程全局量。该模块保留应用挂载所消费的具名 `name`/`inject`/`Config`/`apply` 导出形状;当时的 Echo 和 REPL Loader 冒烟证明组合树,插件形状套件则固定显式 `unwrapExports` 行为。上方取代本文的移除记录负责当前包和示例状态。 - -早期的支持辅助包已移除:其清单、tsconfig 引用、模块图行和 README 行均已消失,其余文档改为描述包内模块。 - -## 曾考虑的替代方案 - -### 为什么不将其提升到 `ui/` 而是折入? - -提升可以解决 support 与 product 之间的错位,同时保留边界——只有在 readline UI 是一个可独立替换的集成或有第二个组合方时才是正确选择,而消费方普查表明两者皆非。结构化的 ACP(Agent Client Protocol)桥接保留为独立包,因为它是具有自身契约和快照层级的自动化协议表面;readline 辅助模块只是一个应用前门的脚手架。在发布前重新拆分成本很低:如果将来有第二个产品应用需要 readline UI,届时再拆出来,由那个消费方来塑造包契约。 - -## 后果 - -- stdio 应用完整拥有自己的前门;叶子 `cordis.yml` 仍然只加载一个应用包,演示的形态没有变化。 -- 未来如果有独立的终端 UI 需要将该辅助模块作为包使用,届时由那个第二消费方驱动重新引入,而非仓库为假设性的复用保留一条边界。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 91e9b078ad..5f30e5d42b 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.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-remove-stdio-and-echo-agents.md: 2aba8193710c96d3726b91062bfa43d039b4cabf -2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c3916683f4743384a2ce4104319da26145837fe +2026-07-20-remove-stdio-and-echo-agents.md: 9f97b1bfb1a446db17dba41ccadafd3d2baf6b5d +2026-07-20-remove-stdio-and-echo-agents.zh.md: 2c162d05b4029ac9d4f6c508ead535dd4a759588 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 2aba819371..9f97b1bfb1 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,8 +18,8 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -28,11 +28,14 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt ## Verification -TUI and Headless Loader coverage run the real app packages in source and built modes. TUI uses a pseudo-terminal; Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, and SDK-interface references. +TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. + +The TUI PTY smoke includes the Code Mode overlay composition, while `examples/cordis-agent/tests/keyless-smoke.e2e.ts` provides a minimal PTY boot over the real Cordis-agent Loader tree. The built TUI bin rejects piped launch before Loader boot and points at `dsh-cli-demo`; the CLI built-bin suite runs text, JSON, and structurally parsed `stream-json` output under plain Node, persists fresh sessions, and rejects invalid arguments and missing config without contaminating stdout. Time-context integration uses the real Headless composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered - **Keep the line agent only for pipes** — rejected because Headless has a bounded task contract, format-pure stdout, durable completion, and process exit status. +- **Keep, fold, or promote the readline helper as a package** — rejected because it had one app consumer and no independently swappable contract. Folding it into the stdio app removed an unjustified support-package boundary but still retained the redundant product; a future standalone line UI needs a real second consumer before reintroducing that package. - **Keep Echo as the keyless quick start** — rejected because the first product experience should exercise the real model and supported coding agent, not a scripted adapter with a bespoke tool. - **Keep Echo only as a CI demo command** — rejected because test-owned Headless fixtures cover the same Loader and built-artifact boundaries without preserving a mock product leaf. - **Remove every stdio or mock mechanism** — rejected because framed protocols, process I/O, and deterministic test adapters are independent infrastructure, not the removed agents. @@ -43,3 +46,4 @@ TUI and Headless Loader coverage run the real app packages in source and built m - The repository has no keyless user-facing agent demo; local agent demos require `DEEPSEEK_API_KEY`. - CI retains keyless real-entry coverage through test fixtures rather than a product command. - Existing stdio-agent configurations, Echo commands, and SDK `--interface=stdio` invocations fail instead of being translated. +- Piped multi-turn interaction in one process and the readline provider for non-TTY `ask_user_question` are intentionally gone; resume covers durable multi-turn work, and a non-TTY composition must supply its own interaction provider. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 2c3916683f..2c162d05b4 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,8 +18,8 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 -- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 +- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -28,11 +28,14 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 ## 验证 -TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。TUI 使用伪终端;Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点和 SDK 接口引用。 +TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果契约和工具调用契约。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 + +TUI PTY 冒烟测试包含 Code Mode 覆盖层组装,而 `examples/cordis-agent/tests/keyless-smoke.e2e.ts` 会基于真实 Cordis-agent Loader 目录树执行最小 PTY 启动。构建后的 TUI 可执行文件会在 Loader 启动前拒绝管道方式启动,并指向 `dsh-cli-demo`;CLI built-bin 套件在普通 Node 下运行文本、JSON 和经过结构化解析的 `stream-json` 输出,持久化新建会话,并在不污染 stdout 的情况下拒绝无效参数和缺失配置。时间上下文集成通过真实 Headless 组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 - **仅为 pipe 保留面向行 agent**:不予采纳,因为 Headless 已提供有界任务契约、格式纯净的 stdout、持久完成边界和进程退出状态。 +- **将 readline helper 作为包保留、折叠或提升**:不予采纳,因为它只有一个 app 消费方,并不存在可独立替换的契约。将它折叠进 stdio app 虽然移除了没有正当理由的支撑包边界,却仍保留了重复产品;将来要重新引入这个包,独立的面向行 UI 必须先有真正的第二个消费方。 - **保留 Echo 作为无密钥快速上手路径**:不予采纳,因为首次产品体验应使用真实模型和受支持的 coding agent,而不是带专用工具的脚本化适配器。 - **只为 CI 演示命令保留 Echo**:不予采纳,因为由测试持有的 Headless fixture 可以覆盖相同的 Loader 和构建产物边界,无需保留 mock 产品叶节点。 - **移除所有 stdio 或 mock 机制**:不予采纳,因为分帧协议、进程 I/O 和确定性测试适配器是独立基础设施,并不是被移除的 agent。 @@ -43,3 +46,4 @@ TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真 - 仓库没有面向用户的无密钥 agent 演示;本地 agent 演示需要 `DEEPSEEK_API_KEY`。 - CI 通过测试 fixture 保留针对真实入口的无密钥覆盖,而不是依赖产品命令。 - 既有 stdio agent 配置、Echo 命令和 SDK `--interface=stdio` 调用会直接失败,不会被转换。 +- 有意移除了单进程内基于管道的多轮交互,以及面向非 TTY `ask_user_question` 的 readline 提供方;恢复会话可以满足持久多轮工作,非 TTY 组装则必须自行提供交互提供方。 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 deleted file mode 100644 index 7b529ad317..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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-20-retire-readline-front-door.md: 166e9ca17989ff14f9c3f38cd9650387581b0f78 -2026-07-20-retire-readline-front-door.zh.md: 8c2568f60c3a12fb16a9ef4fe1775e875966a49a 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 deleted file mode 100644 index 166e9ca179..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: Retire the readline front door and the repl-agent example - -Status: implemented - -English | [中文](2026-07-20-retire-readline-front-door.zh.md) - -## Problem - -The repo shipped two interactive terminal front doors: the line-oriented readline channel (`@deepseek-ai/dsh-stdio`) and the full-screen [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md). After the TUI landed, readline's interactive role was redundant — `demo:tui` superseded `demo:repl` as the coding-agent experience — while its remaining real role, pipes and automation, was already served better by the one-shot `@deepseek-ai/dsh-cli-demo` app (task in, DSH-native `text`/`json`/`stream-json` out, durable persistence, signal handling). - -The duplication was structural, not just cosmetic: `dsh-stdio-demo` carried a `TerminalMode` (`auto`/`readline`/`tui`) selection seam, ~1,000 lines of readline unit tests, a readline transcript grammar (`[tool call] …` lines) that the CI demo smoke and two built-bin e2es grepped, and an inverted example composition where the flagship `tui-agent` leaf was defined as an include-patch over the `repl-agent` leaf it superseded. - -## Decision - -Delete the readline front door and the repl-agent example; keep exactly three front-door archetypes: **interactive TUI** (TTY-only, fails loud on pipes), **one-shot CLI** (`-p`/positional task, pipes and automation), and **servers** (ACP / JSON-RPC). - -- `packages/ui/stdio` and `examples/repl-agent` are gone. `packages/examples/stdio-demo` is renamed `@deepseek-ai/dsh-tui-demo` (`packages/examples/tui-demo`) and always mounts `dsh-tui`; the `TerminalMode`/`resolveTerminalMode`/`ui.mode` seam is deleted. The bin refuses non-TTY streams **before booting the Loader** (a compose-time throw inside a Loader tree is logged per-entry, not rethrown, so a piped launch would otherwise settle into an idle UI-less process instead of exiting nonzero). -- `examples/tui-agent/cordis.yml` now owns the coding composition inline (the include-patch inversion is gone); its Code Mode overlay includes its own base. `examples/cordis-agent` moved to the TUI app. -- `examples/echo-agent` moved to the one-shot `dsh-cli-demo` app; `dsh-cli-demo` gained `-p/--prompt` as the flag form of the single task (mutually exclusive with the positional). -- The UI-independent with-key coding e2es (`full-loop`, `coding-task`, `resume`, `compaction`, `todo-write`, `code-mode` and their shared harness) moved verbatim from `examples/repl-agent/tests/` to `examples/tui-agent/tests/` — they assemble the stack programmatically and never touched a UI. -- The SDK wizard's `stdio` run interface became `tui` (`RunInterface = 'acp' | 'tui' | 'embed'`), contributing a `dsh-tui` entry instead of `dsh-stdio`; the generated `index.ts` guards TTY before `startSDK` for the same pre-boot fail-loud reason as the tui-demo bin. - -### Testing policy: PTY only for the TUI - -Pipes remain the default test medium. PTY-driven subprocess tests are sanctioned **only** where the subject is the TUI itself: `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` (which gained the Code Mode overlay boot scenario, replacing repl-agent's pipe smoke as the overlay's keyless composition proof) and the minimal PTY boot smoke in `examples/cordis-agent` (whose front door IS the TUI). Everything else moved to pipes over the one-shot bin: - -- `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. -- `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 - -- **Piped multi-turn in one process** — the readline channel could script several turns over stdin; the one-shot bin runs one task per process. Multi-turn continuity is covered by `RESUME_SESSION_ID`/resume e2es and the TUI's scripted PTY conversation. -- **Non-TTY `ask_user_question`** — the readline provider was the only non-TTY terminal implementation of `ctx.userInteraction`. A headless or ACP automation run whose model calls `ask_user_question` fails that tool call unless its composition supplies a provider; Web owns the shipped non-terminal provider. - -## Alternatives considered - -- **Keep `dsh-stdio` as a pipe/automation channel without the repl demo** — rejected: its automation role duplicated `dsh-cli-demo` with a weaker contract (unstructured transcript, EOF-exit heuristics vs. one durable turn ending and format-pure output). -- **Rewrite the piped smokes as PTY drivers** — rejected: PTY is the flakier, more complex medium and is reserved for the one surface pipes cannot prove (real TTY takeover/restore). - -## Consequences - -- One interactive front door (TUI), one automation front door (one-shot CLI), two servers; no mode-selection seam in the terminal app. -- ~1,000 lines of readline unit tests deleted with their behavior; the readline transcript grammar is gone from all gates. -- This supersedes the packaging half of [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) (the folded package is now deleted) and amends the composition described in [the TUI front-door note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) (no `auto` selection; `tui-agent` owns the coding composition). 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 deleted file mode 100644 index 8c2568f60c..0000000000 --- a/.agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.zh.md +++ /dev/null @@ -1,46 +0,0 @@ -# Agent Note: 退役 readline 前端与 repl-agent 示例 - -Status: implemented - -[English](2026-07-20-retire-readline-front-door.md) | 中文 - -## 问题 - -仓库同时提供两个交互式终端前端:面向行的 readline 通道(`@deepseek-ai/dsh-stdio`)和全屏的 [`@deepseek-ai/dsh-tui`](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md)。TUI 落地之后,readline 的交互角色已经冗余——`demo:tui` 作为编码 agent 体验取代了 `demo:repl`——而它剩下的真实角色(管道与自动化)已由单次任务的 `@deepseek-ai/dsh-cli-demo` 应用以更好的方式承担(任务输入、DSH 原生 `text`/`json`/`stream-json` 输出、持久化、信号处理)。 - -这种重复是结构性的,不只是表面问题:`dsh-stdio-demo` 携带一个 `TerminalMode`(`auto`/`readline`/`tui`)选择接缝、约 1,000 行 readline 单元测试、一套被 CI 演示冒烟测试和两个 built-bin e2e 用 grep 匹配的 readline 文本记录语法(`[tool call] …` 行),以及一个倒置的示例组合:旗舰 `tui-agent` 叶节点被定义为对它所取代的 `repl-agent` 叶节点的 include patch。 - -## 决定 - -删除 readline 前端和 repl-agent 示例;只保留三类前端原型:**交互式 TUI**(仅 TTY,管道下快速失败)、**单次任务 CLI**(`-p`/位置参数任务,服务管道与自动化)以及**服务器**(ACP / JSON-RPC)。 - -- `packages/ui/stdio` 与 `examples/repl-agent` 已删除。`packages/examples/stdio-demo` 更名为 `@deepseek-ai/dsh-tui-demo`(`packages/examples/tui-demo`)并始终挂载 `dsh-tui`;`TerminalMode`/`resolveTerminalMode`/`ui.mode` 接缝随之删除。bin 在**启动 loader 之前**就拒绝非 TTY 流(Loader 树内组合期抛出的异常按条目记录日志而不会重新抛出,管道启动否则会沉降为一个空闲的无 UI 进程而不是以非零码退出)。 -- `examples/tui-agent/cordis.yml` 现在内联拥有编码组合(include patch 倒置消失);其 Code Mode 覆盖层 include 自己的基础配置。`examples/cordis-agent` 迁移到 TUI 应用。 -- `examples/echo-agent` 迁移到单次任务的 `dsh-cli-demo` 应用;`dsh-cli-demo` 新增 `-p/--prompt` 作为单个任务的旗标形式(与位置参数互斥)。 -- 与 UI 无关的带密钥编码 e2e(`full-loop`、`coding-task`、`resume`、`compaction`、`todo-write`、`code-mode` 及其共享 harness)原样从 `examples/repl-agent/tests/` 移入 `examples/tui-agent/tests/`——它们以编程方式组装整个栈,从不接触任何 UI。 -- SDK 向导的 `stdio` 运行接口改为 `tui`(`RunInterface = 'acp' | 'tui' | 'embed'`),贡献 `dsh-tui` 配置项而不是 `dsh-stdio`;生成的 `index.ts` 在 `startSDK` 之前检查 TTY,理由与 tui-demo bin 的启动前快速失败相同。 - -### 测试策略:PTY 仅用于 TUI - -管道仍是默认测试介质。PTY 驱动的子进程测试**仅**在被测对象就是 TUI 本身时获准使用:`examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`(新增 Code Mode 覆盖层启动场景,取代 repl-agent 的管道冒烟测试成为该覆盖层的无密钥组合证明)和 `examples/cordis-agent` 中最小的 PTY 启动冒烟测试(其前端就是 TUI)。其余全部改为通过单次任务 bin 走管道: - -- `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 套件。 -- `packages/context/time-context/tests/time-context.e2e.ts` 运行一个单次任务轮次;多轮 elapsed 渲染仍由其单元测试覆盖。 - -## 接受的损失 - -- **单进程内的管道多轮对话**——readline 通道可以通过 stdin 脚本化多个轮次;单次任务 bin 每个进程只运行一个任务。多轮连续性由 `RESUME_SESSION_ID`/resume e2e 和 TUI 的脚本化 PTY 对话覆盖。 -- **非 TTY 的 `ask_user_question`**——readline 提供方是 `ctx.userInteraction` 唯一的非 TTY 终端实现。模型调用 `ask_user_question` 的 headless 或 ACP 自动化运行会让该工具调用失败,除非其组合提供相应的 provider;Web 拥有已交付的非终端 provider。 - -## 曾考虑的替代方案 - -- **保留 `dsh-stdio` 作为纯管道/自动化通道而只删 repl 演示**——不予采纳:它的自动化角色以更弱的契约重复了 `dsh-cli-demo`(非结构化文本记录、EOF 退出的启发式判断,对比后者的一次持久轮次结束和格式纯净输出)。 -- **把管道冒烟测试改写为 PTY 驱动**——不予采纳:PTY 是更易波动、更复杂的介质,仅保留给管道无法证明的那一个表面(真实 TTY 的接管/恢复)。 - -## 后果 - -- 一个交互式前端(TUI)、一个自动化前端(单次任务 CLI)、两个服务器;终端应用不再有模式选择接缝。 -- 约 1,000 行 readline 单元测试随其行为一起删除;readline 文本记录语法从所有门禁中消失。 -- 本决定取代 [fold the stdio UI helper](2026-07-04-fold-stdio-ui-helper.md) 的打包部分(被折叠的包现已删除),并修订 [TUI 前端 Agent Note](../feature/2026-07-17-dedicated-full-screen-tui-front-door.md) 描述的组合(不再有 `auto` 选择;`tui-agent` 拥有编码组合)。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml index d0c69e6c43..dbd47ad90e 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.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-22-plan-specific-collaboration-state.md: d6b606d2235b5dbcb7e1882dd34e8965799c1199 -2026-07-22-plan-specific-collaboration-state.zh.md: c0dc22f6ec296a293681b78cebe7cc05f49f774b +2026-07-22-plan-specific-collaboration-state.md: fb26d15238f0eb1b63fdccc7e48a6c49a44236cf +2026-07-22-plan-specific-collaboration-state.zh.md: 93186eebb263458bc99e7f7562d065fbf9e5d4bf diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md index d6b606d223..fb26d15238 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md @@ -1,4 +1,4 @@ -# Agent Note: Collapse named session modes into plan mode +# Agent Note: Plan-specific collaboration state Status: implemented @@ -10,6 +10,8 @@ The first plan-mode implementation introduced a generic named-mode registry even The word “mode” also spans unrelated domains. Sandbox mode is an enforcing policy owned by `ctx.sandboxPolicy` and logged as `sandbox/mode`; plan mode is a collaboration stance that contributes guidance and a reviewed exit. Treating both as instances of one named-mode abstraction would obscure their independent ownership. A transport's generic vocabulary is not evidence that the harness needs a generic mode domain. +Plan mode also needs a durable stance, a reviewable plan artifact, an explicit human boundary, and request reconstruction across resume and fork. Those requirements belong to the plan feature even after the generic registry and interactive ACP projections are removed. + ## Decision Plan mode owns a plan-specific product package: `@deepseek-ai/dsh-plan-mode` at `packages/plan/plan-mode/`. The durable fact is `plan/mode: { active: boolean }`, folded by `foldPlanMode(events)` with `false` as the empty-log value. `ctx.planMode.get(agent)` returns `{ active, pending? }`, and `set(agent, active)` records the boundary-applied selection. The existing prompt-submit, continuation, retry, append-failure, and disposal fences remain unchanged in meaning. @@ -20,6 +22,18 @@ Human-facing compositions own plan selection and review. This note originally ke Sandbox mode and approval policy remain separate enforcement axes. Plan mode neither reads nor writes them, and the simplification introduces no shared base type, registry, or preset abstraction across those concepts. +### Boundary and model contract + +`plan/mode` is log-only and non-surface, so resume, fork, and compaction recover the state without a live mirror. A spawned agent begins inactive because there is no creation-time plan option. Pending user selections flush before the affected request assembly on prompt submission, ordinary continuation, or a request-recovery retry; a failed durable append leaves the intent pending for a later boundary. + +The active state contributes the deployment's section at prompt order 50. Inactive state contributes no section, while `exit_plan_mode` remains registered in both states, so a transition changes the logged request header but not native tool schemas or the Code Mode SDK. A user-driven transition appends one plugin-sourced notice only when the last request header described the opposite state; a pre-first-request or net-zero selection adds none, and an approved tool exit relies on its tool result instead of a second notice. + +### Reviewed exit + +`exit_plan_mode` requires a calling agent in active plan mode and a non-empty markdown plan beginning with a heading. The user-interaction question carries that exact plan as detail and offers `Approve` or `Keep planning` plus free-text feedback. Only one `Approve` selection with no custom text consents; every other answer stays in plan mode and returns corrective feedback to the model. An approved exit becomes a silent pending selection, leaving plan guidance active for the rest of the current tool batch and removing it before the next request. + +The tool renders the submitted plan as a generic card titled by its first heading. An absent or failed user-interaction provider, a failed review, or plugin disposal while review is pending fails closed and leaves manual `/plan off` as the human escape path. + ## Deleted surface - The arbitrary definition map, mode-name regular expression, reserved-name rules, and per-definition command loop. @@ -31,16 +45,27 @@ Sandbox mode and approval policy remain separate enforcement axes. Plan mode nei **Keep a private generic registry and expose only plan today.** Rejected because the unused name/config machinery would still be maintained and tested without a second production consumer. A future collaboration state can establish the right shared seam from two concrete cases. -**Fold sandbox mode into the same service.** Rejected because collaboration guidance and execution confinement have different owners, lifecycle semantics, and consumers. Their shared English noun is not a domain relationship. +**Fold sandbox or approval policy into plan state.** Rejected because collaboration guidance, execution confinement, and permission decisions have different owners, lifecycle semantics, and consumers. A mode-owned sandbox cap also makes a user's explicit sandbox selection appear to succeed while silently doing nothing. **Let one presentation transport own plan state.** Rejected because TUI, Web, resume, fork, prompt assembly, and the exit tool need the same logged fact independently of any one transport. Presentation adapters own only their projections. +**Split a capability-seam trio or put the state in the agent loop.** Rejected because plan mode has no swappable backend, while existing session, prompt, tool, command, and lifecycle seams already provide every required hook. + +**Put flips in surface messages or store plans in files.** Rejected because the stance is a log-only fact and the tool argument already records the reviewable plan. Surface duplication spends model context, while a plan directory creates a second durable home. + +**Filter tools by a per-plan name allowlist or a global policy stack.** Rejected because mutability is a property of each tool, including future and MCP tools, rather than a list that every plan deployment must maintain. Effects metadata can establish a shared policy only when a concrete consumer exists; until then plan mode is guidance, not a security boundary. + +**Review through the approval seam or prose.** Rejected because a plan review is not a permission decision, needs the exact artifact and corrective free text, and must have a logged tool call as its structured transition. The user-interaction seam supplies that contract. + ## Verification - Package tests retain boundary ordering, retry, append-failure, HMR disposal, prompt assembly, stable native and Code Mode schemas, review outcomes, and invariant coverage through the boolean service. - Command tests cover bare `/plan`, `/plan `, active `/plan off`, pending-entry cancellation, inactive idempotence, absence of `/mode` and `/review`, and effect-scoped removal. - The keyless TUI scenarios enter through `/plan `, leave through `/plan off`, and prove that each committed `plan/mode` precedes the request header it changes, the entry message is logged under plan guidance, and the post-exit request omits that guidance. +- The complete `exit_plan_mode` review arc is package-tested but has no assembled-application snapshot after the interactive ACP scenarios were retired; current keyless TUI scenarios cover command entry and direct exit only. ## Consequences The implementation has one vocabulary for one shipped feature. Adding another collaboration stance is an explicit design decision instead of a config entry, and automation clients do not acquire human mode controls through ACP. The migration intentionally rejects old `mode/set` logs and old `modes.plan.section` configuration under the repository's pre-release format policy. + +Plan state remains reconstructable and tool schemas remain stable, but an idle pending selection is lost if the process exits before the next boundary. Entering or leaving plan mode changes the prompt from order 50 onward, and a model that ignores the guidance can still mutate unless the deployment independently configures sandbox, approval, or filesystem policy. diff --git a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md index c0dc22f6ec..93186eebb2 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 将具名会话模式收敛为 plan mode +# Agent Note: plan 专用协作状态 Status: implemented @@ -10,6 +10,8 @@ Status: implemented 「mode」一词还横跨互不相关的领域。沙箱模式是由 `ctx.sandboxPolicy` 拥有、以 `sandbox/mode` 记录日志的强制执行策略;plan mode 则是一种协作方式,会贡献引导内容和经评审的退出路径。若把两者都视为同一个具名模式抽象的实例,就会掩盖二者各自独立的归属关系。传输协议的通用词汇并不能证明 harness 需要通用模式领域。 +Plan mode 还需要持久协作状态、可评审的计划产物、显式人工决策边界,以及跨恢复与 fork 的请求重建。即使移除通用注册表和 ACP 交互投影,这些要求仍归 plan 功能所有。 + ## 决策 Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 的 `@deepseek-ai/dsh-plan-mode`。持久化事实为 `plan/mode: { active: boolean }`,由 `foldPlanMode(events)` 折叠,空日志值为 `false`。`ctx.planMode.get(agent)` 返回 `{ active, pending? }`,`set(agent, active)` 则记录在边界生效的选择。现有的提示词提交、continuation、重试、追加失败和 dispose(资源释放)栅栏在语义上保持不变。 @@ -20,6 +22,18 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` 沙箱模式与审批策略仍是彼此独立的强制约束轴。Plan mode 既不读取也不写入二者;此次简化也没有为这些概念引入共享基类型、注册表或预设抽象。 +### 边界与模型契约 + +`plan/mode` 仅记录到日志且不进入表层,因此恢复、fork 和压缩都能恢复该状态,无需实时镜像。spawn 出的 agent 初始处于未激活状态,因为创建时没有 plan 选项。待生效的用户选择会在提示词提交、普通 continuation 或请求恢复重试时,于受影响的请求组装前写入日志;持久追加失败会让意图保持待定,留到后续边界处理。 + +激活状态在提示词顺序 50 处贡献部署提供的区段。未激活状态不贡献区段,但 `exit_plan_mode` 在两种状态下都保持注册,因此状态转换会改变已记录的请求头,却不改变原生工具 schema 或 Code Mode SDK。用户发起的转换只会在上一条请求头描述相反状态时追加一条来源为插件的通知;第一次请求前的选择或最终状态未变化的选择不会追加通知,经批准的工具退出则依赖其工具结果,不再追加第二条通知。 + +### 经评审的退出 + +`exit_plan_mode` 要求调用方 agent 处于激活的 plan mode,并提交一份非空、以标题开头的 markdown 计划。用户交互问题将这份原样计划作为详情,并提供 `Approve`、`Keep planning` 和自由文本反馈。仅当唯一选择为 `Approve` 且没有自定义文本时才视为同意;其他所有回答都会留在 plan mode,并向模型返回纠正性反馈。经批准的退出会成为一项静默的待生效选择,使 plan 引导在当前工具批次的剩余部分继续有效,并在下一次请求前移除。 + +工具将提交的计划渲染为 generic 卡片,标题取自第一个标题。用户交互提供方缺失或失败、评审失败,或评审待定期间插件被 dispose,都会失败关闭,并保留手动 `/plan off` 作为人类退出路径。 + ## 删除的接口 - 任意定义映射、模式名正则表达式、保留名称规则以及逐定义命令循环。 @@ -31,16 +45,27 @@ Plan mode 拥有一个 plan 专用产品包:位于 `packages/plan/plan-mode/` **保留私有的通用注册表,目前只暴露 plan。** 不予采纳,因为没有第二个生产消费方时,仍需维护和测试未使用的名称与配置机制。未来若出现另一种协作状态,可以从两个具体案例出发建立合适的共享 seam。 -**将沙箱模式折叠进同一服务。** 不予采纳,因为协作引导与执行约束有不同的归属方、生命周期语义和消费方。二者的英文名称都含「mode」,不代表存在领域关系。 +**将沙箱或审批策略折叠进 plan 状态。** 不予采纳,因为协作引导、执行约束和权限决策有不同的归属方、生命周期语义和消费方。由 mode 拥有的沙箱上限还会让用户显式选择沙箱看似成功,实际却被静默忽略。 **让一种呈现传输拥有 plan 状态。** 不予采纳,因为 TUI、Web、恢复、fork、提示词组装和退出工具都需要独立于任何单一传输使用同一项已记录事实。呈现适配器只拥有各自的投影。 +**拆成能力 seam 三包,或把状态放进 agent loop。** 不予采纳,因为 plan mode 没有可替换后端,而现有的会话、提示词、工具、命令和生命周期 seam 已经提供所需的全部钩子。 + +**将状态切换写入表层消息,或把计划存入文件。** 不予采纳,因为协作状态是仅日志事实,工具参数已经记录了可评审的计划。重复写入表层会消耗模型上下文,而计划目录会形成第二个持久归属。 + +**按 plan 专用名称允许列表或全局策略栈筛选工具。** 不予采纳,因为可变性是每个工具自身的属性,包括未来工具和 MCP 工具,而不应由每个 plan 部署维护一份列表。只有出现具体消费方后,effects 元数据才能建立共享策略;在此之前,plan mode 是引导机制,不是安全边界。 + +**通过审批 seam 或普通文本完成评审。** 不予采纳,因为计划评审不是权限决策,需要精确的计划产物和纠正性自由文本,而且必须以已记录的工具调用作为结构化转换。用户交互 seam 提供了这项契约。 + ## 验证 - 包测试通过布尔服务继续覆盖边界顺序、重试、追加失败、HMR(热模块替换)资源释放、提示词组装、稳定的原生 schema 与 Code Mode schema、评审结果和不变式。 - 命令测试覆盖不带参数的 `/plan`、`/plan `、激活状态下的 `/plan off`、取消待生效的进入选择、未激活状态下的幂等性、不存在 `/mode` 和 `/review`,以及随 effect 作用域移除。 - 无密钥 TUI 场景通过 `/plan ` 进入、通过 `/plan off` 退出,并证明每个已提交的 `plan/mode` 都先于其所改变的请求头,进入消息在 plan 引导下记录到日志,且退出后的请求不含该引导。 +- 完整的 `exit_plan_mode` 评审流程有包测试,但交互式 ACP 场景退役后没有组装应用快照;当前无密钥 TUI 场景只覆盖命令进入和直接退出。 ## 后果 该实现只用一套词汇描述一项已交付功能。若要添加另一种协作方式,必须显式作出设计决策,而不能只增加配置项;自动化客户端不会通过 ACP 获得面向人类的模式控制。根据仓库的预发布格式策略,本次迁移有意拒绝旧的 `mode/set` 日志与 `modes.plan.section` 配置。 + +Plan 状态仍可重建,工具 schema 仍保持稳定,但如果进程在下一边界前退出,空闲状态下待生效的选择会丢失。进入或离开 plan mode 会改变提示词顺序 50 处及其后的内容;如果模型忽略引导,仍可能执行修改,除非部署另行配置沙箱、审批或文件系统策略。 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml index 3e4286afec..fbe06d209f 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-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-22-tui-titles-from-session-title-service.md: 735c940dbb8a84104ab4320d5c535b41690953d5 -2026-07-22-tui-titles-from-session-title-service.zh.md: 8e7e3ef070cc6518476fe0f53355cb0705e74c6a +2026-07-22-tui-titles-from-session-title-service.md: 04355b9c426af423dec347997f3b8ac62483eb7f +2026-07-22-tui-titles-from-session-title-service.zh.md: 5fc783c5ca08baba12a60f2aa6b5e286307aa9f0 diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md index 735c940dbb..04355b9c42 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.md @@ -6,11 +6,11 @@ English | [中文](2026-07-22-tui-titles-from-session-title-service.zh.md) ## Problem -Two model-title implementations coexisted after the tui-staging line merged onto master. The TUI carried its own `autoTitle` feature: a fire-and-forget `ctx.llm.stream` call after the first user message that set the terminal window title via OSC 0, with a one-shot latch, its own prompt, its own 40-character cap, and its own resume re-derivation ([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md), [default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md)). Master had meanwhile landed [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md): a `sessionTitle` capability whose accepted revisions are durable `session/title` events, with a deterministic fallback and optional model providers. The TUI already consumed `session/title` for its header subtitle and window title, so a session could be titled twice by different strategies, and the TUI's process-local title was invisible to resume listings, forks, and Web consumers. +A per-session title makes terminal panes and tabs distinguishable, but a TUI-local model call would create a second title pipeline beside [log-backed session titles](../feature/2026-07-21-log-backed-session-titles.md). The local path needs its own prompt, cap, one-shot latch, resume derivation, cancellation, and failure fallback, while its process-local result remains invisible to session listings, forks, Web consumers, and replay. If both paths run, one session can also be titled twice by different strategies. ## Decision -The TUI-local generation is removed; the session-title service is the one title source. `TuiConfig.autoTitle`, the latch, the abort controller, the title prompt, and `titleLine` are gone from `dsh-tui`. The terminal rename stays: the TUI folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and sets the terminal window title to `` on every accepted `session/title` event — including resumed sessions, whose titles now replay from the log instead of being re-generated. +The session-title service is the one title source. The TUI contains no `autoTitle` config, title-model request, latch, abort controller, prompt, or output cap. It folds the latest logged title on mount (`foldSessionTitle`), renders it as the banner subtitle, and calls `runtime.terminal.setTitle` with `` on every accepted `session/title` event. The same terminal-safe OSC 0 path handles the configured fallback title, resumed sessions, and live revisions without renaming tmux windows or adding another terminal-control surface. Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and the scripted PTY fixture) mount `@deepseek-ai/dsh-session-title-first-message-llm`, which inherits the main request's route and replaces the spine's deterministic fallback with a short model summary. Deployments without the provider keep the fallback title from `dsh-agent-spine-demo`'s bundled `SessionTitleService`. @@ -20,6 +20,16 @@ Model-made titles are a composition choice: `examples/tui-agent/cordis.yml` (and **Port auto-title's prompt and cap into the service as a third provider.** The first-message-llm provider already exists with the same cadence, a reviewed prompt contract, durable request records, and supersession fencing; a second near-identical provider would be pure duplication. +**Use only a truncated first prompt or only a model title.** A deterministic fallback provides an immediate, free title, while an optional model provider improves quality without delaying the main turn. Forcing either strategy removes that deployment choice. + +**Make model titles a TUI default or block the first turn for them.** The cost and route belong to composition, and auxiliary title latency must stay off the interaction critical path. The TUI consumes accepted state instead of owning generation policy. + +**Rename a tmux window or use a separate terminal escape.** Rejected because the existing terminal adapter's OSC 0 path labels the pane or tab without acquiring tmux ownership or adding a second control API. + +## Verification + +TUI tests pin restored and live `session/title` consumption, terminal-safe title rendering, the configured fallback, and the absence of a TUI-owned model path. The keyless PTY smoke boots the real composition, accepts a logged provider title, and observes the resulting terminal title. The [log-backed title decision](../feature/2026-07-21-log-backed-session-titles.md) owns provider, persistence, resume, fork, cancellation, and stale-completion coverage. + ## Consequences -One title pipeline: durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI sheds ~90 lines and its `llm`-streaming path. The cost is that a title now requires the provider plugin in the composition for model quality — a leaf choice, not a TUI default — and the terminal title changes shape from the bare model summary to the suffixed ` — <product>` form the log-backed path always used. The superseded auto-title Agent Notes carry pointers here. +One title pipeline is durable, replayable, visible to every consumer, and fenced against stale completions by the service. The TUI has no `llm`-streaming title path. Model quality requires a provider plugin in the composition, while deployments without one keep the deterministic fallback; the terminal title consistently uses the suffixed `<title> — <product>` shape. diff --git a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md index 8e7e3ef070..5fc783c5ca 100644 --- a/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-22-tui-titles-from-session-title-service.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自带 `autoTitle` 特性:在首条用户消息后发起一次 fire-and-forget 的 `ctx.llm.stream` 调用,通过 OSC 0 设置终端窗口标题,带有一次性闩锁、自己的提示词、自己的 40 字符截断和自己的恢复重推导([auto-title Agent Note](../feature/2026-07-21-tui-auto-pane-title.md)、[default-on Agent Note](../feature/2026-07-21-tui-auto-title-default-on.md))。而 master 已落地[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md):一个 `sessionTitle` 能力,其被接受的修订是持久的 `session/title` 事件,带确定性回退和可选的模型 provider。TUI 已经消费 `session/title` 作为横幅副标题和窗口标题,于是一个会话可能被两种策略各标题一次,且 TUI 的进程本地标题对恢复列表、fork 和 Web 消费方不可见。 +每会话标题让终端窗格和标签页易于区分,但 TUI 本地模型调用会在[日志承载的会话标题](../feature/2026-07-21-log-backed-session-titles.md)旁形成第二条标题管线。本地路径需要自己的提示词、截断上限、一次性闩锁、恢复推导、取消和失败回退,而其进程本地结果仍对会话列表、fork、Web 消费方和回放不可见。若两条路径同时运行,同一会话还可能被不同策略命名两次。 ## 决策 -移除 TUI 本地生成;session-title 服务是唯一的标题来源。`TuiConfig.autoTitle`、闩锁、abort controller、标题提示词和 `titleLine` 全部从 `dsh-tui` 删除。终端重命名保留:TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上把终端窗口标题设为 `<会话标题> — <配置标题>` —— 包括恢复的会话,其标题现在从日志回放而不是重新生成。 +session-title 服务是唯一的标题来源。TUI 不包含 `autoTitle` 配置、标题模型请求、闩锁、abort controller、提示词或输出上限。TUI 在挂载时折叠最新的已记录标题(`foldSessionTitle`),将其渲染为横幅副标题,并在每个被接受的 `session/title` 事件上调用 `runtime.terminal.setTitle`,传入 `<session title> — <configured title>`。同一条终端安全的 OSC 0 路径会处理配置的回退标题、恢复的会话和实时修订,既不重命名 tmux 窗口,也不增加另一套终端控制接口。 模型生成的标题是组合选择:`examples/tui-agent/cordis.yml`(以及脚本化 PTY fixture)挂载 `@deepseek-ai/dsh-session-title-first-message-llm`,它继承主请求的确切路由,用简短的模型摘要替换 spine 的确定性回退。未挂载该 provider 的部署保留 `dsh-agent-spine-demo` 内置 `SessionTitleService` 的回退标题。 @@ -20,6 +20,16 @@ tui-staging 分支合入 master 后,两套模型标题实现并存。TUI 自 **把 auto-title 的提示词和截断移植为服务的第三个 provider。** first-message-llm provider 已经存在,节奏相同,且有经过评审的提示词契约、持久的请求记录和替换围栏;再造一个近乎相同的 provider 纯属重复。 +**只使用截断后的首条提示词,或只使用模型标题。** 确定性回退可以立即且免费地提供标题,而可选模型 provider 可以提升质量,不会延迟主轮次。强制采用任一种策略都会移除这项部署选择。 + +**让模型标题成为 TUI 默认行为,或为此阻塞第一个轮次。** 成本与路由归组合所有,辅助标题的延迟不得进入交互关键路径。TUI 只消费已接受的状态,不拥有生成策略。 + +**重命名 tmux 窗口,或使用另一种终端转义序列。** 不予采纳,因为现有终端适配器的 OSC 0 路径可以标记窗格或标签页,无需取得 tmux 归属,也无需增加第二套控制 API。 + +## 验证 + +TUI 测试锁定恢复后和实时的 `session/title` 消费、终端安全的标题渲染、配置的回退标题,以及不存在 TUI 自有模型路径。无密钥 PTY 冒烟测试启动真实组合,接收已记录的 provider 标题,并观察由此产生的终端标题。[日志承载标题决策](../feature/2026-07-21-log-backed-session-titles.md)拥有 provider、持久化、恢复、fork、取消和陈旧完成结果的覆盖。 + ## 影响 -标题管线归一:持久、可回放、对所有消费者可见,并由服务对过期完成设防。TUI 削减约 90 行及其 `llm` 流式路径。代价是模型质量的标题现在需要在组合中挂载 provider 插件 —— 这是叶配置选择,不是 TUI 默认值 —— 且终端标题形状从裸模型摘要变为日志路径一贯使用的 `<标题> — <产品>` 后缀形式。被取代的 auto-title Agent Note 携带指向本文的指针。 +唯一的标题管线持久、可回放、对所有消费方可见,并由服务防止陈旧完成结果生效。TUI 不再有 `llm` 流式标题路径。若要提升模型标题质量,组合中必须挂载 provider 插件;未挂载的部署保留确定性回退。终端标题始终采用 `<title> — <product>` 后缀形式。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 2901a3b813..fe203d8f0f 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.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-acp-automation-only-protocol.md: 2a92f306065b348764f35e1e63f0d7750a636372 -2026-07-23-acp-automation-only-protocol.zh.md: 5889d668310e3bd63f3934a39d4e5250f83f063d +2026-07-23-acp-automation-only-protocol.md: 0fe2fc27a963d21e8a24c1682359ab3bc9e7af48 +2026-07-23-acp-automation-only-protocol.zh.md: 0a471f0bf1b12e835660cdce2d2a2acd761e1b89 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index 2a92f30606..0fe2fc27a9 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -18,20 +18,26 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble The bridge emits only committed `assistant/message` text. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. -One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. +One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-interaction services for ACP. SDK scaffolding likewise treats `ask_user_question` as TUI-only. +The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output. + Disconnect and plugin disposal share one memoized quiescence boundary. Both successful and failed transport closure settle pending prompts as cancelled, dispose every bridge-owned agent, and await loop and session cleanup. A create that loses the close race disposes its unpublished handle. ## Snapshot boundary The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. +Protocol and lifecycle tests pin stop-reason and prompt codecs, version negotiation, fresh-session creation, text and resource-link flattening, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement, per-session cancellation, failed transport closure, ACP-only reload cleanup, and teardown quiescence. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. + ## Alternatives considered **Keep ACP as an editor UI until Web reaches parity.** Rejected because it leaves two interactive contracts to evolve and keeps editor conventions in the automation boundary. +**Keep the earlier editor bridge behind disciplined seams.** Rejected even though that bridge correctly used interface services, tool-owned render intents, approval and user-interaction answerers, harness-owned execution, and a stdout-pure composition. Its terminal cards were capability-gated, display-only Zed `_meta` projections with a text fallback rather than ACP `terminal/create`, so shell execution never left the harness. The projection derived each display terminal id from the stable per-call id to prevent collisions and recovered exit code or signal from the rendered status markers because the pure result presenter received content blocks rather than a structured exit; marker round-trip tests and an explicit no-capability `console` fallback test pinned both contracts. Those boundaries were coherent but could not make editor cards, session navigation, configuration pickers, and human elicitation belong in an automation protocol. + **Replace ACP with a private subagent RPC.** Rejected because ACP already supplies a typed, interoperable process protocol and is used by the out-of-process subagent backend. **Remove machine permission requests with the other interaction features.** Rejected because an automated parent must answer a child agent's one-shot policy decision; this is control flow between agents, not presentation. diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 5889d66831..0a471f0bf1 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -18,20 +18,26 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 桥接层只发出已提交的 `assistant/message` 文本。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 -保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 +保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中的精确 agent 对象;外部请求或缺少调用标识的请求会继续委派;RPC 失败则映射为失败关闭的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) 会以程序化方式使用该通道。 应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。SDK 脚手架同样将 `ask_user_question` 视为 TUI 专属功能。 +传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 + 断开连接与插件 dispose(资源释放)共享同一个经记忆化处理的静止边界。传输关闭无论成功还是失败,都会将待处理提示词以已取消状态结算,dispose 每个由桥接层拥有的 agent,并等待循环和会话清理完成。创建流程如果在与关闭的竞态中落败,就会 dispose 其尚未发布的 handle。 ## 快照边界 ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 +协议与生命周期测试会锁定停止原因编解码器和提示词编解码器、版本协商、新会话创建、文本与资源链接展平、拒绝空提示词或不受支持的提示词、精确 agent 权限归属、多会话隔离、提示词结算、按会话取消、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍属于覆盖豁免,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 + ## 考虑过的替代方案 **在 Web 达到同等能力前,继续将 ACP 作为编辑器 UI。** 不予采用,因为这会留下两套需要演进的交互契约,并使编辑器约定继续存在于自动化边界中。 +**通过严格的 seam 保留早期编辑器桥接层。** 不予采用,尽管该桥接层正确使用了接口服务、工具自有的 render intent、审批与用户交互应答者、harness 自有执行,以及保持 stdout 纯净的组装。其终端卡片是经过能力门控、仅用于展示的 Zed `_meta` 投影,并提供文本回退,而非使用 ACP `terminal/create`,因此 shell 执行从未离开 harness。该投影从稳定的逐调用 id 派生每个展示用终端 id,以避免冲突;由于纯结果展示器接收的是内容块,而不是结构化退出信息,它会从渲染后的状态标记中恢复退出码或信号。标记往返测试和显式的无能力 `console` 回退测试锁定了这两项契约。这些边界保持一致,却无法让编辑器卡片、会话导航、配置选择器和面向人类的询问成为自动化协议应有的职责。 + **用私有 subagent RPC 替换 ACP。** 不予采用,因为 ACP 已经提供类型化、可互操作的进程协议,并由跨进程 subagent 后端使用。 **随其他交互功能一起移除机器权限请求。** 不予采用,因为自动化父 agent 必须回答子 agent 的一次性策略决策;这是 agent 之间的控制流,而不是展示层。 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 45949d3fcf..8b3e3f7391 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.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-06-20-drop-acp-terminal-meta.md: 79da387ac1a7a0e6767e3bf24baa6039e39ef90d -2026-06-20-drop-acp-terminal-meta.zh.md: d29fd54618611f56fd071a0ee4a63bc207895d89 +2026-06-20-drop-acp-terminal-meta.md: d957ba1173af28cb526c92f959a8552f77360a57 +2026-06-20-drop-acp-terminal-meta.zh.md: 3a748c8fdf2ef37d35a14519fee5284af417dd78 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index 79da387ac1..d957ba1173 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -6,7 +6,7 @@ English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) ## Problem -The ACP bridge implements a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) deliberately avoided ACP's client-side `terminal/create` because bash execution belongs in the harness, but still adopted the reference agents' display-only `_meta` convention. That gives a nicer Zed card at the cost of bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing in `dsh-tool-bash`. +The former ACP editor bridge implemented a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The current [render-intent decision](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) preserves the underlying rule that bash execution belongs in the harness and terminal cards are display-only. The later [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes the `_meta` projection, bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing from ACP. The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration. @@ -22,7 +22,7 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 - `TerminalRendering`, terminal ids, terminal cwd resolution, and `_meta.terminal_*` update mapping disappear from `@deepseek-ai/dsh-acp`. - `ToolTerminal` disappears from `@deepseek-ai/dsh-tools`, or is unused and deleted with the presentation cleanup. - Bash result presentation no longer parses exit status for terminal pills. -- The implemented [rich ACP bash rendering Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) stays in `implemented/` as shipped history and is cross-linked from this proposal if superseded. +- The [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) later removes ACP terminal cards and absorbs their execution-ownership rationale. ## What we give up diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index d29fd54618..3a748c8fdf 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -6,7 +6,7 @@ Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是 ## 问题 -ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。已实现的[富 ACP bash 渲染 Agent Note(agent 决策记录)](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md)刻意回避了 ACP 客户端侧的 `terminal/create`(因为 bash 执行属于 harness 职责),但仍采用了参考 agent(智能体)的纯展示 `_meta` 约定。这在 Zed 中带来了更好的卡片效果,代价是桥接状态、能力协商、终端 id、特殊的 update 映射、文本回退测试,以及 `dsh-tool-bash` 中的 exit-pill 解析。 +原 ACP 编辑器桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。当前的 [render-intent 决策](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)保留了底层规则:bash 执行属于 harness,terminal 卡片只用于展示。后续的[仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)从 ACP 中移除了 `_meta` 投影、桥接状态、能力协商、终端 id、特殊 update 映射、文本回退测试和 exit-pill 解析。 回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 @@ -22,7 +22,7 @@ ACP 桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.t - `TerminalRendering`、终端 id、终端 cwd 解析与 `_meta.terminal_*` update 映射从 `@deepseek-ai/dsh-acp` 中消失。 - `ToolTerminal` 从 `@deepseek-ai/dsh-tools` 中消失,或在展示清理中因未使用而删除。 - Bash 结果展示不再为终端 pill 解析退出状态。 -- 已实现的[富 ACP bash 渲染 Agent Note](../../implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) 作为已交付历史保留在 `implemented/` 中;如被本提案取代,则加上交叉链接。 +- [仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)后来移除了 ACP 终端卡片,并吸收了其中有关执行归属的决策依据。 ## 放弃的内容 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 367b986d4c..06a64539f7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -843,7 +843,7 @@ export interface PlanModeConfig { } ``` -Source: [`packages/plan/plan-mode/src/index.ts:58`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:57`](../packages/plan/plan-mode/src/index.ts) ## `@deepseek-ai/dsh-pty-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 86fc08803c..776bdd6b69 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -827,7 +827,7 @@ set(agent: Agent, active: boolean): void Types: [Agent](../core-data-structures/core.md) -Source: [`packages/plan/plan-mode/src/index.ts:142`](../../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:141`](../../packages/plan/plan-mode/src/index.ts) ## `ctx.pty` — `PtyService` diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 0ac134aa41..cfa787d4e3 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -313,7 +313,7 @@ Source: [`packages/ui/permission/src/index.ts:36`](../packages/ui/permission/src 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:41`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:40`](../packages/plan/plan-mode/src/index.ts) ### `prompt/*` diff --git a/packages/plan/README.md b/packages/plan/README.md index 4aedb8c9aa..b40ccc727f 100644 --- a/packages/plan/README.md +++ b/packages/plan/README.md @@ -6,4 +6,4 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product |---|---|---| | `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` | -The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). +The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 8032e83c4c..a6c469d44a 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -29,7 +29,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t `section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy. -Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). +Design: [plan-specific collaboration state](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md). ## Model Experience diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 26ad904d30..be895fb5e1 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -15,8 +15,7 @@ * The exit tool remains registered while plan mode is inactive so crossing a * boundary changes only the prompt section, not the request tool catalog. * - * Agent Notes: - * - .agents/notes/implemented/feature/2026-07-07-plan-mode.md + * Agent Note: * - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md * * @module @deepseek-ai/dsh-plan-mode diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index 4e08844dc9..300a213484 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -3,7 +3,6 @@ "required": [ ".agents/notes/README.md", ".agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md", - ".agents/notes/implemented/architecture/2026-06-11-custom-schema-dsl.md", ".agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md", ".agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md", ".agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md", @@ -44,11 +43,9 @@ ".agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md", ".agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md", ".agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md", - ".agents/notes/implemented/feature/2026-06-14-acp-agent-client-protocol.md", ".agents/notes/implemented/feature/2026-06-14-acp-multi-session.md", ".agents/notes/implemented/feature/2026-06-15-code-mode.md", ".agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md", - ".agents/notes/implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md", ".agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md", ".agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md", ".agents/notes/implemented/feature/2026-06-22-acp-subagent-backend.md", @@ -90,7 +87,6 @@ ".agents/notes/implemented/process/2026-07-06-export-surface-jsdoc-gate.md", ".agents/notes/implemented/process/2026-07-06-generated-config-catalog.md", ".agents/notes/implemented/process/2026-07-06-node-engine-floor.md", - ".agents/notes/implemented/process/2026-07-06-parallel-github-ci-gates.md", ".agents/notes/implemented/process/2026-07-06-parallel-pre-push-gates.md", ".agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md", ".agents/notes/implemented/process/2026-07-12-package-model-experience-contract.md", @@ -108,7 +104,6 @@ ".agents/notes/implemented/simplification/2026-07-04-drop-image-content-block.md", ".agents/notes/implemented/simplification/2026-07-04-drop-inert-request-knobs.md", ".agents/notes/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md", - ".agents/notes/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md", ".agents/notes/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md", ".agents/notes/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md", ".agents/notes/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md", From d616d4ca507f7a528b2362eb46cdb092a1395405 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 16:49:45 +0800 Subject: [PATCH 43/70] 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 <path>`, `-p/--prompt <task>`, `--resume <id>` — 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 <path>` 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 <path>` 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=<id> [--config <path>]`, so `replaceResumeArg` (which the merge brought in) is dropped alongside `parseResumeArg`. +`dsh --resume <id>` 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=<id> [--config <path>]`. ## 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 <path>` 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 <path>`, 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 <path>`. `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 <config>` 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 <config>` 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 <config>` or `RESUME_SESSION_ID=<id> dsh-tui-demo` uses `dsh <config>` / `dsh --resume <id>` 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 <path>`、`-p/--prompt <task>`、`--resume <id>`——而 `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 <path>` 取代了先前的配置位置参数。`dsh` 是不接受位置参数的产品入口;该标志仅用于让演示和测试调用点(`demo:cordis`、`demo:code-mode`、无密钥 PTY 冒烟测试)通过随产品提供的 bin 启动另一份示例树。直接运行 `dsh` 会启动随产品提供的配置树,并叠加 `~/.dsh/config.yaml` 个人覆盖;实际用户从不传入 `--config`。 +`dsh` 不接受位置参数。`--config <path>` 指定一份替代 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=<id> [--config <path>]`,因此合并时引入的 `replaceResumeArg` 与 `parseResumeArg` 一并删除。 +`dsh --resume <id>` 是恢复持久化会话的唯一方式,无需环境变量。`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=<id> [--config <path>]`。 ## 唯一的终端入口:`dsh` -`dsh-tui-demo` 包(package)原本包含一个插件(即 `dsh` 配置挂载的 TUI 应用组合)和一个冗余的 `bin`;后者启动一份叶子配置 `cordis.yml`,所做的工作与 `dsh --config <path>` 相同。该 bin 已移除:`demo:cordis`、`demo:code-mode` 以及 tui-agent 和 cordis-agent 的两个无密钥 PTY 冒烟测试现在都通过 `apps/cli/src/bin.ts` 启动,并传入 `--config <path>`;该包只保留插件入口和不变式入口。与该 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 <path>`。`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 <config>` 位置参数(以及它迫使系统采用的保留 `web` token 分发机制)。** 已否决:根级位置参数与真正的 `web` 子命令无法在同一个 Commander 程序中共存(子命令会占用第一个位置参数)。因此,先前版本才会把开头保留的 `web` token 分发给第二个解析器,并在 `--help` 中手工拼接一行 `web` 文本。该位置参数仅用于让演示和测试调用点通过随产品提供的 bin 启动另一份示例树。将其替换为 `--config` 标志后,默认接口不再包含任何位置参数,`web` 因而成为单个程序中的普通子命令,并由原生 `--help` 展示;保留 token 分发、第二个解析器和手工拼接的帮助文本均被删除。`dsh` 没有损失任何用户所需的功能,演示调用则改用显式标志。 +**使用裸 `dsh <config>` 位置参数指定替代配置树。** 已否决:根级位置参数与真正的 `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 <config>` 或 `RESUME_SESSION_ID=<id> dsh-tui-demo` 的用户,改用 `dsh <config>` 或 `dsh --resume <id>`。 +`dsh` 会渲染 `--help`/`--version`,并以一致方式显式报告解析错误;模式路由不依赖标志位置。argv 解析集中在一处,并与 SDK bin 共用一套解析器方式,代价是 `apps/cli` 依赖 `commander`,且 Commander 的解析语义(错误字符串和 `exitOverride` 契约)成为 CLI 入口的一部分。`dsh-app-boot` 不提供任何 CLI 解析接口;需要 `--resume` 式解析的消费方通过组合 Commander 来实现。会话恢复通过启动上下文完成,而不使用环境变量;`dsh` 是唯一的终端入口;`dsh-tui-demo` 包是由配置挂载的插件组合包。 From 45034edd1a1816238e6eada2bc03178aa8d770e9 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 17:20:49 +0800 Subject: [PATCH 44/70] docs(skills): teach Agent Note consolidation --- .agents/notes/README.i18n.yaml | 4 ++-- .agents/notes/README.md | 2 ++ .agents/notes/README.zh.md | 2 ++ ...nt-notes-for-non-trivial-changes.i18n.yaml | 4 ++-- ...ire-agent-notes-for-non-trivial-changes.md | 5 +++++ ...-agent-notes-for-non-trivial-changes.zh.md | 5 +++++ .../skills/dsh-find-simplifications/SKILL.md | 22 +++++++++++++++++-- 7 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index 3853edbc6b..17bbe70379 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.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 -README.md: a0f01a68ccd838ec405392679d20e7316fba78ef -README.zh.md: 2df46224569daed0ac3a469ce0799018301df195 +README.md: 6dec68bef44350895d30058b994ddacb63c70822 +README.zh.md: a9d8e6c74c757271841b3f9ad208fe5e6350d645 diff --git a/.agents/notes/README.md b/.agents/notes/README.md index a0f01a68cc..6dec68bef4 100644 --- a/.agents/notes/README.md +++ b/.agents/notes/README.md @@ -41,6 +41,8 @@ Updating the Agent Note that already owns the decision satisfies the rule; do no An implemented Agent Note that is fully superseded may be consolidated into the current owning note and deleted. Before deletion, the owner must preserve every unique rationale, alternative, consequence, verification contract, and named coverage gap; repair every inbound link; and delete any Chinese counterpart, consistency record, and `required` entry in [the translation-pairing manifest](../../scripts/translation-pairing.manifest.json) in the same change. Partial supersession does not qualify: keep both notes cross-linked and update every fact that remains current. Consolidation must not rewrite the old file into its opposite or rely on git history as the only copy of rationale. +A feature-addition note may be consolidated into the later removal note only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the original motivation, why it no longer justified the feature, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Obsolete implementation inventories and tests that only verified the deleted behavior are not current verification contracts. Removing one transport, default, implementation, or presentation is partial supersession, as is any surviving durable data or compatibility handling. + ## The file format Every Agent Note follows one in-file format, enforced by `pnpm run verify-agent-note-format` ([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts), part of `doc-sync`); the rationale for the format — and the alternatives it rejected — is [the uniform-format Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md). diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index 2df4622456..a9d8e6c74c 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -43,6 +43,8 @@ 被完全取代的 implemented Agent Note 可以合并到当前持有该决策的记录中,并删除原文件。删除前,当前记录必须保存所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口;修复所有入站链接;并在同一变更中删除中文对侧文件、一致性记录,以及[翻译配对 manifest(元数据清单)](../../scripts/translation-pairing.manifest.json)中对应的 `required` 条目。仅部分被取代的记录不符合此条件:保留两个记录并让它们互相链接,同时更新所有仍然适用的事实。合并不得将旧文件改写成与其相反的决策,也不得让 git 历史成为决策依据的唯一副本。 +只有当一项功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中完全消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行时,新增该功能的 Agent Note 才可合并进后续的移除记录。移除决策的依据和验证该功能已不存在的测试可以保留。移除决策的持有记录必须保留最初动机、为什么该动机已不足以证明保留该功能的合理性、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。过时的实现清单和只验证已删除行为的测试不属于当前验证契约。仅移除一种传输、默认值、实现或展示属于部分取代;仍有任何持久数据或兼容处理也同样如此。 + <a id="the-file-format"></a> ## 文件格式 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml index bd1468da18..a0226ea124 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.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-19-require-agent-notes-for-non-trivial-changes.md: b9f631706437f380eb87422bdf7f4b8f83932a64 -2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 85265cd11e15575f07f14a34f68c6956b720fe67 +2026-07-19-require-agent-notes-for-non-trivial-changes.md: 32d7408b3d56e6571a14a8191e9b4b0fe901f5a3 +2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md: 713845706e650b4b4acd591368d9bcff137a38b7 diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md index b9f6317064..32d7408b3d 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.md @@ -16,6 +16,8 @@ Updating the note that already owns a decision satisfies the rule; a new note is A fully superseded implemented note may be consolidated into the current owning note and deleted only after that owner preserves every unique rationale, alternative, consequence, verification contract, and named coverage gap. The same change repairs inbound links and removes any Chinese counterpart, consistency record, and `required` entry in `scripts/translation-pairing.manifest.json`. Partial supersession keeps both notes cross-linked and current; consolidation neither rewrites an old decision into its opposite nor leaves git history as the only copy of rationale. +When a later decision removes an earlier feature completely, the removal note becomes the current owner only after the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that verify absence may remain. The removal owner preserves the feature's original motivation, why that motivation no longer justified the surface, alternatives to full removal, the capability given up, conditions for reintroduction, and verification of complete absence. Implementation inventories and tests that only described the deleted behavior are obsolete rather than current verification contracts. A removal limited to one transport, default, implementation, or presentation remains partial supersession. + Review enforces the semantic boundary. No automated gate attempts to classify a diff as trivial or non-trivial, so this policy adds no gate stage or runtime. ## Alternatives considered @@ -30,6 +32,8 @@ Review enforces the semantic boundary. No automated gate attempts to classify a **Rewrite the old note into the replacement decision.** This erases the decision boundary and its rejected alternatives. Consolidation instead preserves those facts in the current owner before deleting the obsolete file. +**Preserve every implementation and test detail from a removed feature.** This recreates the obsolete note inside its replacement. The removal owner keeps the rationale and verification needed to understand or revisit the current absence, while deleted mechanics remain available in git history. + **Add a CI diff-classification gate.** A mechanical check cannot reliably determine whether a semantic change is trivial, while another gate adds runtime and invites false positives or superficial compliance. ## Consequences @@ -37,5 +41,6 @@ Review enforces the semantic boundary. No automated gate attempts to classify a - Every substantial change preserves its rationale and rejected alternatives beside the implementation. - Contributors maintain an existing owning note instead of creating duplicate records. - Fully superseded records can collapse into one current owner without losing their unique rationale or verification contract. +- Features that were later removed can have one current owner without carrying obsolete implementation and test inventories forward. - Partial supersession remains explicit and cross-linked, while deletion requires link, bilingual-pair, and required-manifest cleanup in the same change. - Mechanical edits remain lightweight, and the gate topology and runtime remain unchanged. diff --git a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md index 85265cd11e..713845706e 100644 --- a/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md +++ b/.agents/notes/implemented/process/2026-07-19-require-agent-notes-for-non-trivial-changes.zh.md @@ -16,6 +16,8 @@ Status: implemented 只有在当前持有该决策的记录保存了所有独有的决策依据、备选方案、影响、验证契约和明确指出的覆盖缺口后,才可将被完全取代的 implemented Agent Note 合并到该记录中并删除。同一变更还要修复入站链接,并删除中文对侧文件、一致性记录,以及 `scripts/translation-pairing.manifest.json` 中对应的 `required` 条目。仅部分被取代时,两个记录仍需互相链接并保持与现状一致;合并既不将旧决策改写成与其相反的决策,也不让 git 历史成为决策依据的唯一副本。 +后续决策完全移除较早的功能时,只有该功能已从生产代码、配置、schema、持久化格式或协议格式、迁移和兼容行为中消失,当前文档不再将其描述为可用,且没有测试把它作为受支持行为来执行,移除记录才会成为当前持有记录。移除决策的依据和验证该功能已不存在的测试可以保留。它必须保留该功能的最初动机、为什么该动机已不足以证明继续保留该功能、完全移除之外的备选方案、放弃的能力、重新引入的条件,以及证明已彻底移除的验证。只描述已删除行为的实现清单和测试已经过时,不属于当前验证契约。仅移除一种传输、默认值、实现或展示仍属于部分取代。 + 评审负责执行这条语义边界。自动化门禁不尝试把差异分类为平凡或实质性变更,因此这项政策不会增加门禁阶段或运行时间。 ## 备选方案 @@ -30,6 +32,8 @@ Status: implemented **将旧 Agent Note 改写为替代它的决策。** 这样会抹去决策边界及其否决的备选方案。合并做法是在删除过时文件前,先由当前持有决策的记录保存这些事实。 +**保留已移除功能的每一项实现与测试细节。** 这会在替代记录中重建过时记录。移除决策的持有记录只保留理解或重新审视当前已移除状态所需的决策依据与验证,已删除机制仍可从 git 历史查看。 + **添加 CI 差异分类门禁。** 机械检查无法可靠判断语义变更是否平凡,额外门禁还会增加运行时间,并引入误报或表面合规。 ## 影响 @@ -37,5 +41,6 @@ Status: implemented - 每项实质性变更都会在实现旁保留其决策依据和被放弃的备选方案。 - 贡献者维护现有的决策持有记录,而不是创建重复记录。 - 被完全取代的记录可以归并到一个当前持有记录中,同时不丢失其独有的决策依据或验证契约。 +- 后来被移除的功能可以只有一个当前持有记录,而无需继续保留过时的实现与测试清单。 - 仅部分被取代的情况仍需明确记录并互相链接;删除记录则必须在同一变更中清理链接、双语配对和 `scripts/translation-pairing.manifest.json` 的 `required` 条目。 - 机械编辑仍保持轻量,门禁拓扑和运行时间也保持不变。 diff --git a/.agents/skills/dsh-find-simplifications/SKILL.md b/.agents/skills/dsh-find-simplifications/SKILL.md index 43e2218085..1c3b07e5a1 100644 --- a/.agents/skills/dsh-find-simplifications/SKILL.md +++ b/.agents/skills/dsh-find-simplifications/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-find-simplifications -description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates and write proposed Agent Notes or inline TODO/FIXME/XXX notes for dead, duplicated, speculative, or over-built code surfaces; especially for requests like "find simplification Agent Notes", "look for unnecessary complexity", "audit for removal-style cleanups", or "fold worthwhile simplification ideas from another PR".' +description: 'Use when working in the deepseek-harness repo to find non-obvious simplification candidates, write proposed Agent Notes or inline TODO/FIXME/XXX notes, audit or coalesce superseded Agent Notes, or fold worthwhile simplification ideas from another PR; especially for dead, duplicated, speculative, over-built, or added-then-removed surfaces.' --- # Finding DeepSeek Harness Simplifications @@ -66,6 +66,22 @@ Reject or downgrade a candidate when: - The removal would force unrelated churn without actually making the contract smaller. - The idea is correct but tiny. Add a targeted TODO/FIXME/XXX instead, using the urgency semantics in [docs/development.md](../../../docs/development.md). +## Coalesce Superseded Agent Notes + +Audit the Agent Note tree when the user asks to reduce or coalesce it, or when the simplification being implemented makes an owning note obsolete. Do not expand every code-simplification survey into a repository-wide note audit. + +Follow the deletion rule in the [Agent Note contract](../../notes/README.md#when-to-write-one); do not duplicate or weaken it here. For each candidate chain: + +1. Identify the current owner from shipped code, configuration, generated catalogs, package docs, newer Agent Notes, and inbound links; dates and titles are discovery hints, not proof. +2. Classify the old note as fully or partially superseded. Any surviving behavior, current contract, durable format, compatibility obligation, or independently current rejected alternative makes it partial. Rationale that can be transferred to the current owner does not by itself make supersession partial. +3. For full supersession, move every unique rationale, alternative, consequence, shipped verification contract, and named coverage gap into the current owner. An inventory that only describes deleted implementation mechanics is not one of those decision facts. +4. Repair every inbound link, then delete the English note, Chinese counterpart, consistency record, and required-pair manifest entry together. +5. Search exact filenames, symbols, config keys, event names, and wire strings after the edit. Keep partial supersessions cross-linked and current. + +An added-then-removed feature is a common full-supersession case. Let the removal note own the history only when the feature is absent from production code, configuration, schemas, durable or wire formats, migration, and compatibility behavior; no current documentation presents it as available; and no test exercises it as supported behavior. Removal rationale and tests that enforce absence may remain. Preserve why the feature originally existed, why that motivation no longer justified it, alternatives to full removal, the capability given up, conditions for reintroduction, and evidence that removal is complete. Old tests and implementation mechanics that verified only the deleted behavior are not current verification contracts. + +Reject consolidation when the removal is only one transport, default, implementation, or presentation of a feature; when persisted data or compatibility handling survives; or when the removal note does not yet carry enough rationale to prevent accidental reintroduction. A current negative design decision may legitimately need its own note even though the removed implementation is gone. + ## Write The Agent Note Create one file per durable proposal under `.agents/notes/<lifecycle>/<class>/yyyy-mm-dd-topic.md`, following the lifecycle/classification contract in `.agents/notes/README.md`. Keep prose paragraphs on one physical line and use relative Markdown links. @@ -106,9 +122,11 @@ For docs-only Agent Note work, run at least `pnpm run doc-sync`, `pnpm run lint` When opening or updating a PR, summarize: -- How many Agent Notes and inline notes were added. +- How many Agent Notes and inline notes were added, consolidated, retained as partial supersessions, or deleted. - The main areas surveyed. - What was intentionally excluded. - Which checks passed. +For each consolidation group, name the old and current owners, state the evidence for full supersession, and explain why deletion is safe. If an added-then-removed scan finds no qualifying note, report that result and the representative partial cases retained. + Use a draft PR while the survey is still expanding; mark ready only when the candidate set, review responses, and validation are settled. From 5a06b9e92612ee92126d671a9b69b027507efca8 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 17:24:39 +0800 Subject: [PATCH 45/70] 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 <path>`. 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 <path>`, `-p/--prompt <task>`, `--resume <id>` — 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 <path>`, `-p/--prompt <task>`, `--resume <id>` — 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 <path>` 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 <path>`、`-p/--prompt <task>`、`--resume <id>`——而 `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 <path>`、`-p/--prompt <task>`、`--resume <id>`——而 `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 <path>` 指定一份替代 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 <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)') .option('--port <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-<uuid>` 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 <path-to-cordis.yml>` 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 <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 17:37:50 +0800 Subject: [PATCH 46/70] chore: retrigger CI (synchronize event was missed) From 5209656086944adcbbf637468b89bb99799c9633 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 17:43:26 +0800 Subject: [PATCH 47/70] docs: clarify PR label selection --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 8c4cc143f8..6ec6383d6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs appropriately.** Apply labels required by each PR's changes. +- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From fd8d93da12903b8c9d8455cab0972ff272f36bc0 Mon Sep 17 00:00:00 2001 From: Turtle <turtle1999@deepseek.com> Date: Sat, 25 Jul 2026 18:02:36 +0800 Subject: [PATCH 48/70] docs: prohibit creating PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 6ec6383d6a..eae3007c86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Use matching existing PR labels (`documentation`, `web`, `tui`, `core`); never create one implicitly. +- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From b78daaad8c97484754d5fb0fe706d4cac41c3dfa Mon Sep 17 00:00:00 2001 From: Hypatia May <hypatiamay@outlook.com> Date: Sat, 25 Jul 2026 18:14:36 +0800 Subject: [PATCH 49/70] 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<br/>@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_acp_tool_session_query - plugin_acp_timeout_policy["timeout-policy<br/>@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_acp_timeout_policy - plugin_acp_spill_local["spill-local<br/>@deepseek-ai/dsh-spill-local"] - cfg --> plugin_acp_spill_local - plugin_acp_spill_policy["spill-policy<br/>@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_acp_spill_policy plugin_acp_token_meter["token-meter<br/>@deepseek-ai/dsh-token-meter"] cfg --> plugin_acp_token_meter plugin_acp_compact_basic["compact-basic<br/>@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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; - /** 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<string, JsonValue>; /** 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. <!-- dsh-user-approval-policy:ask --> 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`). +<!-- dsh-user-approval-policy:never --> + +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> 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 <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`)." + }, + "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<br/>@deepseek-ai/dsh-tool-session-query"] - cfg --> plugin_tui_tool_session_query plugin_tui_session_title_llm["session-title-llm<br/>@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm plugin_tui_token_meter["token-meter<br/>@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<P>` 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<HostHandle> { 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', From be3a9f6a75ba2d1c86b2e738c1ccc95018073522 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:05:29 +0800 Subject: [PATCH 50/70] docs: document repository clean workflow --- .../process/2026-06-17-ts-build-config.i18n.yaml | 4 ++-- .../implemented/process/2026-06-17-ts-build-config.md | 8 ++++++++ .../implemented/process/2026-06-17-ts-build-config.zh.md | 8 ++++++++ AGENTS.md | 1 + 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 80f7838d45..32202d837f 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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-06-17-ts-build-config.md: 1275a635242ea887c941db9dc0554fd33acd4102 -2026-06-17-ts-build-config.zh.md: 7691118dd152874e2849f1ace686069c9ea3f61f +2026-06-17-ts-build-config.md: 527570393c42d581d28da0380efdf9ba8bade8ae +2026-06-17-ts-build-config.zh.md: 6535add99115bff0e396e87729bef225dae39e6c diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 1275a63524..527570393c 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,6 +43,8 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. +Composite projects keep their incremental build information inside their package-local `lib/` output. `pnpm run clean` explicitly removes package/vendor/CLI `lib/` outputs, legacy root build information, and deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. + The command orchestration shape is: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. @@ -63,6 +68,8 @@ tsc -b - **Keep `tsdown`/oxc as the TypeScript transformer** — oxc's transform is not `tsc` behavior (decorator transform differs, bundled JS differs from per-file emit), and its bundled `.d.ts` conflicts with Cordis' internal relative module augmentation shape. - **One root strict program over packages, vendor, examples, tests, and scripts** — vendor source triggers type errors outside this project's ownership under the root strict flags; project references with per-project strictness are the boundary that works. +- **Clean before every build** — this would discard the incremental state owned by `tsc` and the bundler even when the workspace layout is unchanged. +- **Remove every package-level `node_modules`** — valid package dependency links do not cause the workspace-discovery failure, and deleting them would turn build cleanup into dependency reinstallation. ## Consequences @@ -76,5 +83,6 @@ Build responsibilities are clearer: - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. - `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. - The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. +- After changing branches or updating a checkout that deleted packages, contributors can run `pnpm run clean` to remove stale package directories before rebuilding. Unknown files in a manifest-less package directory require manual classification instead of being deleted. The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 7691118dd1..6535add991 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,6 +43,8 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 +复合项目将增量构建信息保存在各包本地的 `lib/` 输出中。`pnpm run clean` 会显式删除包、vendor 和 CLI(命令行界面)的 `lib/` 输出、遗留的根目录构建信息,以及已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 + 命令编排结构如下: ```sh @@ -55,6 +57,9 @@ tsx scripts/verify-node-next-types.ts pnpm run typecheck: tsc -b + +pnpm run clean: +tsx scripts/clean.ts ``` `pnpm run demo:*` 仍通过 tsx 和根路径直接运行 `src`,无需编译步骤。 @@ -63,6 +68,8 @@ tsc -b - **继续使用 `tsdown`/oxc 作为 TypeScript 转换器**:oxc 的转换行为与 `tsc` 不同(装饰器转换有差异、打包 JS 与逐文件输出不同),且其打包 `.d.ts` 与 Cordis 内部的相对模块增强结构冲突。 - **用一个根目录严格程序覆盖包、vendor、示例、测试和脚本**:vendor 源码在根目录严格标志下会触发不属于本项目所有权范围的类型错误;带有逐项目严格度的 project references 才是可行的边界。 +- **每次构建前都执行清理**:即使工作区布局没有变化,这也会丢弃 `tsc` 和打包器拥有的增量状态。 +- **删除所有包级 `node_modules`**:有效的包依赖链接不会导致工作区发现失败,而删除这些链接会使构建清理变成重新安装依赖。 ## 后果 @@ -76,5 +83,6 @@ tsc -b - `lib/index.*` 是发布用的运行时输出,由打包器(当前为 `tsdown`)生成。 - `pnpm run verify-node-next-types` 扫描构建出的声明文件,检查是否存在缺少文件扩展名的相对说明符,然后以 `moduleResolution: "NodeNext"` 对构建出的 `types`/`exports` 接口进行临时外部 ESM 消费方的类型检查,确保声明说明符的回归在发布前被捕获。 - `typecheck` 命令使用 `tsconfig.json`。示例、测试和脚本由根 no-emit 项目检查,包和 vendor 模块保持与 `build` 相同的输出行为。包和 vendor 源码始终处于 project-reference 边界之后。 +- 切换分支或更新工作副本后,如果其中删除了包,贡献者可在重新构建前运行 `pnpm run clean`,删除残留的包目录。不含 `package.json` 的包目录如果存在未知文件,必须手动判定其类别,不能直接删除。 Cordis 的 vendor 副本现在与上游多了一处类型结构差异。在上游同步时,该差异必须被重新应用或明确废弃。 diff --git a/AGENTS.md b/AGENTS.md index 64259784fb..1ce8e5e0c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,7 @@ Package groups: [packages/README.md](packages/README.md). ```sh pnpm install # pnpm workspaces, node ^22.19 || >=24 +pnpm run clean # remove build outputs and safe residue from deleted packages pnpm run test # vitest unit tests pnpm run test:coverage # CI coverage gate: per-file 100% on packages/*/*/src pnpm run test:e2e # real-API tests; self-skip without DEEPSEEK_API_KEY From 22875a6c9d86d801cffaaa8d6af42aed76ea3463 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:06:01 +0800 Subject: [PATCH 51/70] fix(build): clean stale workspace residue --- package.json | 2 +- packages/examples/cli-demo/tsconfig.json | 3 +- scripts/clean.ts | 107 +++++++++++++++++++++++ 3 files changed, 109 insertions(+), 3 deletions(-) create mode 100644 scripts/clean.ts diff --git a/package.json b/package.json index a925ea3ec9..f5017bea2f 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "scripts": { "build": "tsc -b && tsdown", "build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build", - "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", + "clean": "tsx scripts/clean.ts", "typecheck": "tsc -b", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/packages/examples/cli-demo/tsconfig.json b/packages/examples/cli-demo/tsconfig.json index df5758b7b8..095f7ce6a3 100644 --- a/packages/examples/cli-demo/tsconfig.json +++ b/packages/examples/cli-demo/tsconfig.json @@ -3,8 +3,7 @@ "compilerOptions": { "composite": true, "rootDir": "src", - "outDir": "lib/types", - "tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo" + "outDir": "lib/types" }, "include": ["src/**/*.ts"], "references": [ diff --git a/scripts/clean.ts b/scripts/clean.ts new file mode 100644 index 0000000000..3f0f85836f --- /dev/null +++ b/scripts/clean.ts @@ -0,0 +1,107 @@ +import { lstat, readdir, rm } from 'node:fs/promises' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' + +const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) + +function isMissing(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} + +async function exists(path: string): Promise<boolean> { + try { + await lstat(path) + return true + } catch (error) { + if (isMissing(error)) return false + throw error + } +} + +async function childDirectories(path: string): Promise<string[]> { + try { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name)) + } catch (error) { + if (isMissing(error)) return [] + throw error + } +} + +function repositoryPath(root: string, path: string): string { + return relative(root, path).split(sep).join('/') +} + +class RepositoryCleaner { + constructor(private readonly root: string) {} + + /** + * Remove generated build state and package directories containing only known residue. + * @returns Repository-relative paths that were removed. + */ + async clean(): Promise<string[]> { + const targets = await this.plan() + for (const target of targets) await rm(target, { recursive: true, force: true }) + return targets.map(target => repositoryPath(this.root, target)) + } + + private async plan(): Promise<string[]> { + const targets = new Set<string>() + const unsafeOrphans: string[] = [] + + await this.addIfPresent(targets, join(this.root, '.typecheck')) + for (const entry of await readdir(this.root, { withFileTypes: true })) { + if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) + } + + for (const vendorDirectory of await childDirectories(join(this.root, 'vendor'))) { + await this.addIfPresent(targets, join(vendorDirectory, 'lib')) + } + await this.addIfPresent(targets, join(this.root, 'apps', 'cli', 'lib')) + + for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { + for (const packageDirectory of await childDirectories(groupDirectory)) { + if (await exists(join(packageDirectory, 'package.json'))) { + await this.addIfPresent(targets, join(packageDirectory, 'lib')) + continue + } + + const entries = await readdir(packageDirectory) + const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) + if (unknown.length > 0) { + unsafeOrphans.push(...unknown.map(entry => repositoryPath(this.root, join(packageDirectory, entry)))) + } else { + targets.add(packageDirectory) + } + } + } + + if (unsafeOrphans.length > 0) { + throw new Error([ + 'clean: refusing to remove package directories without package.json; unknown entries remain:', + ...unsafeOrphans.sort().map(path => ` ${path}`), + ].join('\n')) + } + + return [...targets].sort() + } + + private async addIfPresent(targets: Set<string>, path: string): Promise<void> { + if (await exists(path)) targets.add(path) + } +} + +const scriptPath = fileURLToPath(import.meta.url) +if (process.argv[1] !== undefined && resolve(process.argv[1]) === scriptPath) { + try { + const removed = await new RepositoryCleaner(resolve(dirname(scriptPath), '..')).clean() + if (removed.length === 0) { + console.log('clean: already clean') + } else { + console.log(`clean: removed ${removed.length} paths`) + } + } catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + } +} From 787b4e6b9e1dbe1e88649bbc4061dee4b8912819 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:30:35 +0800 Subject: [PATCH 52/70] fix(build): derive clean outputs from project graph --- .../2026-06-17-ts-build-config.i18n.yaml | 4 +- .../process/2026-06-17-ts-build-config.md | 2 +- .../process/2026-06-17-ts-build-config.zh.md | 2 +- scripts/clean.spec.ts | 62 ++++++++++++++ scripts/clean.ts | 80 +++++++++++++++++-- 5 files changed, 140 insertions(+), 10 deletions(-) create mode 100644 scripts/clean.spec.ts diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml b/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml index 32202d837f..d8530ac919 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.i18n.yaml +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.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-06-17-ts-build-config.md: 527570393c42d581d28da0380efdf9ba8bade8ae -2026-06-17-ts-build-config.zh.md: 6535add99115bff0e396e87729bef225dae39e6c +2026-06-17-ts-build-config.md: 17036438b83a77f72b49f55abf29632af3f4ffef +2026-06-17-ts-build-config.zh.md: 70e49c61deba418894a48be3016898d1d85c78a0 diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md index 527570393c..17036438b8 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.md @@ -43,7 +43,7 @@ In-package relative imports use explicit `.ts` specifiers. - Referenced package/vendor projects keep the same emit behavior as build, so typecheck refreshes their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. - The no-emit aggregates disable `rewriteRelativeImportExtensions`; they emit nothing and include tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. -Composite projects keep their incremental build information inside their package-local `lib/` output. `pnpm run clean` explicitly removes package/vendor/CLI `lib/` outputs, legacy root build information, and deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. +Composite projects keep their incremental build information inside their project-local `lib/` output. `pnpm run clean` derives live output directories from the root TypeScript project-reference graph, removes legacy root build information, and removes deleted `packages/*/*` directories that contain only known generated residue. It preserves `node_modules` for every package that still has a `package.json`, and refuses to remove a manifest-less directory containing unknown files. Build does not invoke clean automatically, so ordinary builds retain incremental state. The command orchestration shape is: diff --git a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md index 6535add991..70e49c61de 100644 --- a/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md +++ b/.agents/notes/implemented/process/2026-06-17-ts-build-config.zh.md @@ -43,7 +43,7 @@ Status: implemented - 被引用的包/vendor 项目保持与构建相同的输出行为,因此类型检查会刷新它们的 `lib/types` 输出,而无需使用独立的 no-emit 图。项目特定的严格度变更放在各自的 `packages/*/*/tsconfig.json` 或 `vendor/*/tsconfig.json` 中。 - 两个 no-emit 聚合禁用 `rewriteRelativeImportExtensions`;它们不输出任何文件,且包含跨 project-reference 边界导入 helper 的测试。包/vendor 的 emit 项目保持重写开启。 -复合项目将增量构建信息保存在各包本地的 `lib/` 输出中。`pnpm run clean` 会显式删除包、vendor 和 CLI(命令行界面)的 `lib/` 输出、遗留的根目录构建信息,以及已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 +复合项目将增量构建信息保存在各项目本地的 `lib/` 输出中。`pnpm run clean` 会根据根 TypeScript project-reference 图确定当前有效的输出目录,删除遗留的根目录构建信息,并删除已删除包留下且仅包含已知生成残留的 `packages/*/*` 目录。对于仍有 `package.json` 的每个包,该命令都会保留 `node_modules`;如果不含 `package.json` 的目录中存在未知文件,则拒绝删除。构建不会自动调用 clean,因此常规构建会保留增量状态。 命令编排结构如下: diff --git a/scripts/clean.spec.ts b/scripts/clean.spec.ts new file mode 100644 index 0000000000..0d3aced888 --- /dev/null +++ b/scripts/clean.spec.ts @@ -0,0 +1,62 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { RepositoryCleaner } from './clean.ts' + +const roots: string[] = [] + +function fixture(): string { + const root = mkdtempSync(join(tmpdir(), 'dsh-clean-')) + roots.push(root) + return root +} + +function write(path: string, content = ''): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, content) +} + +function addProject(root: string, path: string): void { + write(join(root, 'tsconfig.json'), JSON.stringify({ files: [], references: [{ path }] })) + write(join(root, path, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { composite: true, outDir: 'lib/types' }, + include: ['src'], + })) + write(join(root, path, 'src/index.ts'), 'export {}\n') +} + +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +describe('RepositoryCleaner', () => { + it('derives live build outputs from project references and removes safe stale package residue', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'products/shell/lib/index.js')) + write(join(root, '.typecheck/legacy.tsbuildinfo')) + write(join(root, 'root.tsbuildinfo')) + write(join(root, 'packages/removed/ghost/node_modules/.bin/tool')) + + await new RepositoryCleaner(root).clean() + + expect(existsSync(join(root, 'products/shell/lib'))).toBe(false) + expect(existsSync(join(root, 'products/shell/src/index.ts'))).toBe(true) + expect(existsSync(join(root, '.typecheck'))).toBe(false) + expect(existsSync(join(root, 'root.tsbuildinfo'))).toBe(false) + expect(existsSync(join(root, 'packages/removed/ghost'))).toBe(false) + }) + + it('does not delete any target when a manifest-less package contains an unknown file', async () => { + const root = fixture() + addProject(root, 'products/shell') + write(join(root, 'products/shell/lib/types/index.js')) + write(join(root, 'packages/removed/ghost/notes.txt')) + + await expect(new RepositoryCleaner(root).clean()).rejects.toThrow('packages/removed/ghost/notes.txt') + expect(existsSync(join(root, 'products/shell/lib'))).toBe(true) + }) +}) diff --git a/scripts/clean.ts b/scripts/clean.ts index 3f0f85836f..93b9b2519c 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -1,9 +1,21 @@ import { lstat, readdir, rm } from 'node:fs/promises' -import { dirname, join, relative, resolve, sep } from 'node:path' +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' +import ts from 'typescript' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) +const configHost: ts.ParseConfigFileHost = { + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, + readDirectory: (...args) => ts.sys.readDirectory(...args), + fileExists: fileName => ts.sys.fileExists(fileName), + readFile: fileName => ts.sys.readFile(fileName), + getCurrentDirectory: () => ts.sys.getCurrentDirectory(), + onUnRecoverableConfigFileDiagnostic(diagnostic) { + throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) + }, +} + function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } @@ -32,7 +44,17 @@ function repositoryPath(root: string, path: string): string { return relative(root, path).split(sep).join('/') } -class RepositoryCleaner { +function parseConfig(configPath: string): ts.ParsedCommandLine { + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) + if (parsed.errors.length > 0) { + throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) + } + return parsed +} + +/** Plans and removes repository-owned build output without crossing the repository boundary. */ +export class RepositoryCleaner { constructor(private readonly root: string) {} /** @@ -41,6 +63,7 @@ class RepositoryCleaner { */ async clean(): Promise<string[]> { const targets = await this.plan() + // Planning validates every target first, so an unsafe orphan prevents all deletion. for (const target of targets) await rm(target, { recursive: true, force: true }) return targets.map(target => repositoryPath(this.root, target)) } @@ -49,23 +72,29 @@ class RepositoryCleaner { const targets = new Set<string>() const unsafeOrphans: string[] = [] + // These checks cover legacy root-level incremental state emitted by older configs. await this.addIfPresent(targets, join(this.root, '.typecheck')) for (const entry of await readdir(this.root, { withFileTypes: true })) { if (entry.isFile() && entry.name.endsWith('.tsbuildinfo')) targets.add(join(this.root, entry.name)) } - for (const vendorDirectory of await childDirectories(join(this.root, 'vendor'))) { - await this.addIfPresent(targets, join(vendorDirectory, 'lib')) + // The root project-reference graph is the source of truth for live build targets. + // Each emitting project declares lib/types as outDir; its parent lib also owns + // the sibling runtime bundles, so the complete build output root is removed. + for (const outputDirectory of this.buildOutputDirectories()) { + await this.addIfPresent(targets, outputDirectory) } - await this.addIfPresent(targets, join(this.root, 'apps', 'cli', 'lib')) for (const groupDirectory of await childDirectories(join(this.root, 'packages'))) { for (const packageDirectory of await childDirectories(groupDirectory)) { + // A package.json marks a live package; its output was discovered from the + // project graph above, and its package-local node_modules must be preserved. if (await exists(join(packageDirectory, 'package.json'))) { - await this.addIfPresent(targets, join(packageDirectory, 'lib')) continue } + // A manifest-less package directory is stale only when every remaining + // entry is known generated residue; unknown files make the whole clean fail. const entries = await readdir(packageDirectory) const unknown = entries.filter(entry => !knownOrphanEntries.has(entry) && !entry.endsWith('.tsbuildinfo')) if (unknown.length > 0) { @@ -86,7 +115,46 @@ class RepositoryCleaner { return [...targets].sort() } + private buildOutputDirectories(): string[] { + const outputs = new Set<string>() + const pending = [join(this.root, 'tsconfig.json')] + const visited = new Set<string>() + + while (pending.length > 0) { + const nextConfigPath = pending.pop() + if (nextConfigPath === undefined) break + const configPath = resolve(nextConfigPath) + if (visited.has(configPath)) continue + visited.add(configPath) + + const parsed = parseConfig(configPath) + if (parsed.options.outDir !== undefined) { + const typesDirectory = resolve(parsed.options.outDir) + if (basename(typesDirectory) !== 'types') { + throw new Error(`clean: expected TypeScript outDir to end in /types: ${repositoryPath(this.root, typesDirectory)}`) + } + const outputDirectory = dirname(typesDirectory) + this.assertRepositoryTarget(outputDirectory) + outputs.add(outputDirectory) + } + + for (const reference of parsed.projectReferences ?? []) { + pending.push(ts.resolveProjectReferencePath(reference)) + } + } + + return [...outputs] + } + + private assertRepositoryTarget(path: string): void { + const repositoryRelative = relative(this.root, path) + if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) { + throw new Error(`clean: refusing build output outside repository: ${path}`) + } + } + private async addIfPresent(targets: Set<string>, path: string): Promise<void> { + // Missing outputs are normal on a clean checkout; only existing paths become deletion targets. if (await exists(path)) targets.add(path) } } From badf7d1c631a06d1d565c7efc832742d69493de2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang <jyc132780208@sina.com> Date: Sat, 25 Jul 2026 22:45:58 +0800 Subject: [PATCH 53/70] fix(llm-mock-server): harden fault boundaries --- ...scriptable-llm-wire-fault-server.i18n.yaml | 4 +- ...-07-25-scriptable-llm-wire-fault-server.md | 8 ++-- ...-25-scriptable-llm-wire-fault-server.zh.md | 8 ++-- docs/module-graph.md | 3 ++ .../tests/transport-recovery.spec.ts | 20 +++++---- packages/support/llm-mock-server/README.md | 4 +- packages/support/llm-mock-server/src/cli.ts | 16 +++++-- packages/support/llm-mock-server/src/index.ts | 31 +++++++++---- .../support/llm-mock-server/tests/cli.spec.ts | 3 ++ .../llm-mock-server/tests/server.spec.ts | 43 +++++++++++++++++++ 10 files changed, 109 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml index 4eea19c9c0..c9cebc9db9 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.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-25-scriptable-llm-wire-fault-server.md: 92f7d6aad8e7b4dc8bb08e98bb5847ff27470229 -2026-07-25-scriptable-llm-wire-fault-server.zh.md: 2f5fcc1321b0e4f501f3814e5e96d4b26cf18ec6 +2026-07-25-scriptable-llm-wire-fault-server.md: 0795f71f0eab1a107740aaa8cba6fa04b1fbd306 +2026-07-25-scriptable-llm-wire-fault-server.zh.md: a0e3c98d729adcc74e6d98933ab2beb538f71cb3 diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md index 92f7d6aad8..0795f71f0e 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.md @@ -18,11 +18,11 @@ Request behaviors cover socket reset, post-header disconnect, partial disconnect The `random` script entry performs a new weighted selection for every request. The server exposes and logs its unsigned 32-bit seed, accepts caller-supplied relative weights, and ships a success-heavy stress profile that mixes transport, protocol, provider, timeout, and semantic-empty outcomes. The profile is configurable test pressure rather than an estimate of production incident frequency; `connection_refused` remains outside the request-level pool. -The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, and idle timeout recover under the existing default policy; a valid content-less completion succeeds without retry; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. +The server reports wire facts only and does not classify retryability. Real-composition tests route it through `dsh-llm-deepseek`, `dsh-agent-loop`, and `dsh-llm-retry`: connection refusal, hard disconnect, partial reset, idle timeout, and a valid content-less completion recover under the existing default policy; clean partial EOF remains `STREAM_CLOSED` and is not retried by default. The package does not change those policies. ## Verification -Package tests exercise every request behavior, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing, random seed reproducibility, weight validation, telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, empty-success semantics, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. +Package tests exercise every request behavior, split UTF-8 request decoding, HTTP validation without script consumption, script exhaustion/repetition, stalled-connection teardown, CLI parsing and delay bounds, IPv6 base URLs, random seed reproducibility, weight validation, single-result telemetry, lifecycle cleanup, and the invariant companion under the per-file coverage gate. The retry integration suite proves exact request counts, numbered retry steps, request-body identity, failed partial-chunk isolation, semantic-empty recovery, clean-EOF classification, timeout recovery, true refused-connection recovery after delayed listener startup, and bounded exhaustion through the real HTTP/SSE adapter. ## Alternatives considered @@ -32,10 +32,10 @@ Package tests exercise every request behavior, HTTP validation without script co **Use only an in-process `LlmAdapter` mock** — rejected because it bypasses fetch, HTTP status/header parsing, SSE framing, socket termination, and the adapter idle watchdog: the exact boundaries this test infrastructure exists to exercise. -**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Adding `STREAM_CLOSED` or semantic-empty recovery requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. +**Change retry defaults with the server** — rejected because the server reveals existing semantics rather than deciding policy. Extending recovery to `STREAM_CLOSED` requires a separate decision with its own cost, latency, and duplicate-generation trade-offs. ## Consequences -Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and successful empty completions without splicing attempts or modifying model history. +Developers can reproduce fault sequences by changing only provider URL/key configuration, and automated tests can keep socket-level failures deterministic through explicit scripts and seeds. The same wire fixture now exposes gaps between hard resets, clean truncation, and recovered empty completions without splicing attempts or modifying model history. The server adds a support package, executable build entry, and behavior vocabulary that must remain compatible with both direct tests and CLI examples. Arrival-ordered scripts are intentionally shared across clients, random defaults are stress weights rather than operational truth, and exact connection refusal requires coordinating the client attempt with the pre-listen interval. diff --git a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md index 2f5fcc1321..a0e3c98d72 100644 --- a/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md +++ b/.agents/notes/implemented/testing/2026-07-25-scriptable-llm-wire-fault-server.zh.md @@ -18,11 +18,11 @@ Status: implemented 脚本项 `random` 会为每个请求重新执行一次加权选择。服务器公开并记录其无符号 32 位 seed,允许调用方提供相对权重,并内置一套偏重成功结果的压力测试配置,将传输、协议、提供方、超时和语义空结果混合在一起。该配置用于提供可调的测试压力,并非对生产事故发生频率的估算;`connection_refused` 仍不进入请求级随机池。 -服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置以及空闲超时均可恢复;合法的无内容完成无需重试即可成功;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 +服务器只报告协议层事实,不判断是否可重试。真实组合测试让请求依次经过 `dsh-llm-deepseek`、`dsh-agent-loop` 和 `dsh-llm-retry`:在现有默认策略下,连接遭拒、硬断开、部分输出后重置、空闲超时以及合法的无内容完成均可恢复;正常关闭的部分输出 EOF 仍归类为 `STREAM_CLOSED`,默认不重试。该包不会改变这些策略。 ## 验证 -包测试覆盖所有请求行为、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析、随机 seed 可复现性、权重校验、遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、空完成成功语义、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 +包测试覆盖所有请求行为、跨分片 UTF-8 请求解码、不消耗脚本的 HTTP 校验、脚本耗尽与重复、停滞连接清理、CLI 解析及延迟边界、IPv6 base URL、随机 seed 可复现性、权重校验、单结果遥测、生命周期清理,以及逐文件覆盖率门禁下的配套不变式插件。重试集成套件通过真实 HTTP/SSE(Server-Sent Events)适配器,验证准确的请求次数、带编号的重试步骤、请求体完全一致、失败的部分分片不会泄漏、语义空结果恢复、正常 EOF 分类、超时恢复、监听器延迟启动后从真实连接遭拒中恢复,以及有界重试耗尽。 ## 曾考虑的替代方案 @@ -32,10 +32,10 @@ Status: implemented **仅使用进程内的 `LlmAdapter` mock**:不予采纳。它会绕过 fetch、HTTP 状态与 header 解析、SSE 分帧、socket 终止以及适配器的空闲看门狗,而这正是这套测试基础设施要覆盖的边界。 -**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否为 `STREAM_CLOSED` 或语义空结果增加恢复能力,需要单独决策,并权衡成本、延迟和重复生成风险。 +**随服务器一起修改默认重试策略**:不予采纳。服务器用于揭示既有语义,而非决定策略。是否将恢复能力扩展到 `STREAM_CLOSED`,需要单独决策,并权衡成本、延迟和重复生成风险。 ## 后果 -开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与成功空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 +开发者只需修改提供方 URL/key 配置即可复现故障序列;自动化测试则可通过显式脚本和 seed,让 socket 层故障保持确定性。同一套协议 fixture 现在可以暴露硬重置、正常截断与恢复后的空完成之间的差异,而不会拼接多次尝试的内容或修改模型历史。 服务器新增了一个支持包、可执行构建入口和行为词汇,三者必须同时兼容直接测试与 CLI 示例。按请求到达顺序执行的脚本有意由所有客户端共享;随机模式的默认值代表压力测试权重,而非实际运行规律;精确模拟连接遭拒时,需要让客户端尝试与监听开始前的时间区间协调一致。 diff --git a/docs/module-graph.md b/docs/module-graph.md index 9e8b3adf8a..ca1c58c101 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -117,6 +117,7 @@ flowchart TD pkg_acp_snapshot["acp-snapshot"] pkg_agent_loop_testkit["agent-loop-testkit"] pkg_invariants["invariants"] + pkg_llm_mock_server["llm-mock-server"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] end @@ -224,6 +225,7 @@ flowchart TD pkg_skill --> pkg_invariants pkg_subagent_subprocess --> pkg_invariants pkg_acp_snapshot --> pkg_invariants + pkg_llm_mock_server --> pkg_invariants pkg_loader_smoke --> pkg_invariants pkg_client_i18n --> pkg_invariants pkg_client_modules --> pkg_invariants @@ -797,6 +799,7 @@ flowchart TD | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants) | | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | [`invariants`](../packages/support/invariants) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants) | +| [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`invariants`](../packages/support/invariants) | | [`client-i18n`](../packages/client/i18n) | `client` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 790e3e22f7..a93bbbc4b6 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -140,8 +140,11 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { expect(finalAssistantText(agent)).toBe('recovered response') }) - it('treats a wire-valid content-less completion as success without retrying', async () => { - const server = await start(['empty', 'success'], { apiKey: 'mock-key' }) + it('retries a wire-valid content-less completion without committing an empty message', async () => { + const server = await start(['empty', 'success'], { + apiKey: 'mock-key', + successText: 'recovered from empty', + }) context = await harness(server.baseURL) const agent = context.agentLoop.create(SessionId('wire-empty'), { provider: 'deepseek', @@ -150,16 +153,17 @@ describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => { await sendAndWait(context, agent) - expect(server.requests).toHaveLength(1) - expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false) - expect(agent.session.events.find(event => event.type === 'assistant/message')).toMatchObject({ - data: { turn: 1, step: 1, content: [] }, - }) + expect(server.requests).toHaveLength(2) + expect(server.requests[0]?.body).toEqual(server.requests[1]?.body) + expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code)) + .toEqual(['EMPTY_RESPONSE']) + expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step)) + .toEqual([2]) expect(agent.session.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } }, }) - expect(finalAssistantText(agent)).toBeUndefined() + expect(finalAssistantText(agent)).toBe('recovered from empty') }) it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => { diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index 20efe731d5..6fca303b47 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -2,7 +2,7 @@ A scriptable OpenAI-compatible HTTP/SSE server for exercising real LLM adapters, the agent loop, and recovery policy without a provider key. It accepts `POST /chat/completions` and `POST /v1/chat/completions`; each accepted request consumes one configured behavior in arrival order. Invalid methods, paths, bearer tokens, and JSON do not consume the script. -The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections. +The library entry exports `startMockLlmServer(options)`, behavior and telemetry types, the default random stress weights, the accepted Node timer bound, and a running handle with the bound `baseURL`, generated or configured `randomSeed`, captured requests, and idempotent `close()`. Closing force-terminates stalled connections. ## Standalone use @@ -67,7 +67,7 @@ When random weights include `stall`, configure the client under test with a shor ## Timing and content controls -The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer <token>`; omission accepts any token. +The CLI exposes `--success-text`, `--partial-text`, `--reasoning-text`, `--chunk-size`, `--chunk-delay-ms`, `--disconnect-delay-ms`, `--retry-after-ms`, `--request-id`, `--tool-name`, and `--tool-arguments`. Millisecond delays are bounded integers within Node's timer range; `retryAfterMs` must also be positive. The library accepts the same camel-case options. An optional exact `apiKey` validates `Authorization: Bearer <token>`; omission accepts any token. ## Model Experience diff --git a/packages/support/llm-mock-server/src/cli.ts b/packages/support/llm-mock-server/src/cli.ts index 12d10072f2..786a74c0f4 100644 --- a/packages/support/llm-mock-server/src/cli.ts +++ b/packages/support/llm-mock-server/src/cli.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-llm-mock-server/cli */ -import { MOCK_LLM_BEHAVIORS } from './index.ts' +import { MAX_MOCK_LLM_TIMER_DELAY_MS, MOCK_LLM_BEHAVIORS } from './index.ts' import type { ConcreteMockLlmBehavior, MockLlmBehavior, @@ -18,7 +18,7 @@ export const CONNECTION_REFUSED_BEHAVIOR = 'connection_refused' export interface MockLlmCliConfig { /** Server options after removing the lifecycle-only `connection_refused` entry. */ readonly server: MockLlmServerOptions - /** Delay before binding the model port; zero starts immediately. */ + /** Delay before binding the model port; an integer from zero through the Node timer maximum. */ readonly listenDelayMs: number /** Whether the original sequence requested a true pre-listen refusal phase. */ readonly startsUnavailable: boolean @@ -77,6 +77,14 @@ function numberValue(option: string, value: string): number { return parsed } +function boundedIntegerValue(option: string, value: string, min: number, max: number): number { + const parsed = numberValue(option, value) + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`dsh-llm-mock-server: ${option} must be an integer between ${min} and ${max}`) + } + return parsed +} + function parseSequence(raw: string): { startsUnavailable: boolean; sequence: MockLlmBehavior[] } { const entries = raw.split(',').map(entry => entry.trim()) if (entries.some(entry => entry.length === 0)) { @@ -154,7 +162,9 @@ export function parseMockLlmCliArgs(argv: readonly string[]): MockLlmCliParseRes case '--host': host = value; break case '--port': port = numberValue(option, value); break case '--api-key': apiKey = value; break - case '--listen-delay-ms': listenDelayMs = numberValue(option, value); break + case '--listen-delay-ms': + listenDelayMs = boundedIntegerValue(option, value, 0, MAX_MOCK_LLM_TIMER_DELAY_MS) + break case '--seed': randomSeed = numberValue(option, value); break case '--random-weights': randomWeights = parseRandomWeights(value); break case '--success-text': successText = value; break diff --git a/packages/support/llm-mock-server/src/index.ts b/packages/support/llm-mock-server/src/index.ts index 45dda839e2..b07a2bb09b 100644 --- a/packages/support/llm-mock-server/src/index.ts +++ b/packages/support/llm-mock-server/src/index.ts @@ -9,7 +9,7 @@ import { createServer } from 'node:http' import type { IncomingHttpHeaders, IncomingMessage, ServerResponse } from 'node:http' import { randomBytes } from 'node:crypto' -import type { AddressInfo } from 'node:net' +import { isIP, type AddressInfo } from 'node:net' import { setTimeout as delay } from 'node:timers/promises' /** Request-scoped behaviors accepted by {@link startMockLlmServer}. */ @@ -69,6 +69,9 @@ export const DEFAULT_MOCK_LLM_RANDOM_WEIGHTS: Readonly<MockLlmRandomWeights> = O malformed_json: 1, }) +/** Largest millisecond delay accepted by Node timers without truncation. */ +export const MAX_MOCK_LLM_TIMER_DELAY_MS = 2_147_483_647 + /** How one accepted request ended at the mock boundary. */ export type MockLlmRequestOutcome = 'completed' | 'reset' | 'stalled' | 'client_closed' | 'server_error' @@ -186,7 +189,6 @@ interface ResolvedOptions { readonly onEvent?: (event: MockLlmServerEvent) => void } -const MAX_TIMER_DELAY_MS = 2_147_483_647 const DEFAULT_SUCCESS_TEXT = 'mock response recovered' const DEFAULT_PARTIAL_TEXT = 'discarded partial response' const DEFAULT_REASONING_TEXT = 'mock reasoning' @@ -203,14 +205,24 @@ function resolveOptions(options: MockLlmServerOptions): ResolvedOptions { const host = options.host ?? '127.0.0.1' const port = boundedInteger('port', options.port ?? 0, 0, 65_535) const chunkSize = boundedInteger('chunkSize', options.chunkSize ?? 8, 1, Number.MAX_SAFE_INTEGER) - const chunkDelayMs = boundedInteger('chunkDelayMs', options.chunkDelayMs ?? 25, 0, MAX_TIMER_DELAY_MS) + const chunkDelayMs = boundedInteger( + 'chunkDelayMs', + options.chunkDelayMs ?? 25, + 0, + MAX_MOCK_LLM_TIMER_DELAY_MS, + ) const disconnectDelayMs = boundedInteger( 'disconnectDelayMs', options.disconnectDelayMs ?? 10, 0, - MAX_TIMER_DELAY_MS, + MAX_MOCK_LLM_TIMER_DELAY_MS, + ) + const retryAfterMs = boundedInteger( + 'retryAfterMs', + options.retryAfterMs ?? 1_000, + 1, + MAX_MOCK_LLM_TIMER_DELAY_MS, ) - const retryAfterMs = boundedInteger('retryAfterMs', options.retryAfterMs ?? 1_000, 1, MAX_TIMER_DELAY_MS) const randomSeed = boundedInteger( 'randomSeed', options.randomSeed ?? randomBytes(4).readUInt32LE(0), @@ -285,8 +297,9 @@ function emit(options: ResolvedOptions, event: MockLlmServerEvent): void { } async function readJsonBody(request: IncomingMessage): Promise<unknown> { - let body = '' - for await (const chunk of request) body += Buffer.from(chunk).toString('utf8') + const chunks: Buffer[] = [] + for await (const chunk of request) chunks.push(Buffer.from(chunk as Uint8Array)) + const body = Buffer.concat(chunks).toString('utf8') return body.length === 0 ? undefined : JSON.parse(body) } @@ -320,6 +333,7 @@ function finishRecord( record: MockLlmRequestRecord, outcome: MockLlmRequestOutcome, ): void { + if (record.outcome !== undefined) return record.outcome = outcome emit(options, { type: 'result', @@ -713,8 +727,9 @@ export async function startMockLlmServer(options: MockLlmServerOptions): Promise }) const address = server.address() as AddressInfo + const advertisedHost = isIP(resolved.host) === 6 ? `[${resolved.host}]` : resolved.host return { - baseURL: `http://${resolved.host}:${address.port}`, + baseURL: `http://${advertisedHost}:${address.port}`, port: address.port, randomSeed: resolved.randomSeed, requests, diff --git a/packages/support/llm-mock-server/tests/cli.spec.ts b/packages/support/llm-mock-server/tests/cli.spec.ts index 66a3868963..12c5bd6926 100644 --- a/packages/support/llm-mock-server/tests/cli.spec.ts +++ b/packages/support/llm-mock-server/tests/cli.spec.ts @@ -110,6 +110,9 @@ describe('mock LLM server CLI parser', () => { [['--sequence', 'unknown'], /unknown behavior/], [['--sequence', 'connection_refused,success', '--port', '0'], /nonzero/], [['--sequence', 'success', '--listen-delay-ms', '5'], /requires connection_refused/], + [['--sequence', 'connection_refused,success', '--listen-delay-ms', '-1'], /integer between 0 and 2147483647/], + [['--sequence', 'connection_refused,success', '--listen-delay-ms', '1.5'], /integer between 0 and 2147483647/], + [['--sequence', 'connection_refused,success', '--listen-delay-ms', '2147483648'], /integer between 0 and 2147483647/], [['--sequence', 'success', '--seed', '1'], /require random/], [['--sequence', 'random', '--random-weights', 'success'], /expects behavior=weight/], [['--sequence', 'random', '--random-weights', 'random=1'], /concrete behavior/], diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index b84931bc9f..1f15ba1a0b 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,3 +1,4 @@ +import { request } from 'node:http' import { afterEach, describe, expect, it } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -32,6 +33,22 @@ function chat( }) } +function rawChat(server: MockLlmServer, chunks: readonly Buffer[]): Promise<void> { + return new Promise((resolve, reject) => { + const outgoing = request(`${server.baseURL}/v1/chat/completions`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + }, (response) => { + response.once('error', reject) + response.once('end', resolve) + response.resume() + }) + outgoing.once('error', reject) + for (const chunk of chunks) outgoing.write(chunk) + outgoing.end() + }) +} + describe('mock LLM server wire behaviors', () => { it('streams a complete text response and captures the request', async () => { const events: MockLlmServerEvent[] = [] @@ -151,10 +168,12 @@ describe('mock LLM server wire behaviors', () => { ['stream_disconnect', 100] as const, ['partial_disconnect', 100] as const, ])('records a client that closes during %s', async (behavior, delayMs) => { + const events: MockLlmServerEvent[] = [] const server = await start([behavior], { chunkDelayMs: delayMs, disconnectDelayMs: delayMs, chunkSize: 1, + onEvent: (event) => { events.push(event) }, }) const controller = new AbortController() const response = await chat(server, { signal: controller.signal }) @@ -163,6 +182,30 @@ describe('mock LLM server wire behaviors', () => { await new Promise((resolve) => { setTimeout(resolve, 5) }) expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + expect(events.filter(event => event.type === 'result')).toEqual([ + expect.objectContaining({ behavior, outcome: 'client_closed' }), + ]) + }) + + it('preserves UTF-8 code points split across request chunks', async () => { + const server = await start(['success']) + const encoded = Buffer.from(JSON.stringify({ messages: [{ role: 'user', content: '你好' }] })) + const characterOffset = encoded.indexOf(Buffer.from('你')) + expect(characterOffset).toBeGreaterThanOrEqual(0) + + await rawChat(server, [ + encoded.subarray(0, characterOffset + 1), + encoded.subarray(characterOffset + 1), + ]) + + expect(server.requests[0]?.body).toEqual({ messages: [{ role: 'user', content: '你好' }] }) + }) + + it('formats an IPv6 listener as a valid base URL', async () => { + const server = await start(['success'], { host: '::1' }) + + expect(server.baseURL).toMatch(/^http:\/\/\[::1\]:\d+$/) + expect((await chat(server)).status).toBe(200) }) it('emits reasoning, tool calls, max-token finishes, slow chunks, and a wrong content type', async () => { From 204a09b70fbfa6ebfd8ea39ac69ce7c0a7a2538e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:30:20 +0800 Subject: [PATCH 54/70] fix(build): reuse TypeScript config host --- scripts/clean.ts | 14 ++------------ scripts/ts-project.ts | 5 +++-- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/scripts/clean.ts b/scripts/clean.ts index 93b9b2519c..3cb3820db7 100644 --- a/scripts/clean.ts +++ b/scripts/clean.ts @@ -2,20 +2,10 @@ import { lstat, readdir, rm } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { fileURLToPath } from 'node:url' import ts from 'typescript' +import { repositoryConfigHost } from './ts-project.ts' const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck']) -const configHost: ts.ParseConfigFileHost = { - useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, - readDirectory: (...args) => ts.sys.readDirectory(...args), - fileExists: fileName => ts.sys.fileExists(fileName), - readFile: fileName => ts.sys.readFile(fileName), - getCurrentDirectory: () => ts.sys.getCurrentDirectory(), - onUnRecoverableConfigFileDiagnostic(diagnostic) { - throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')) - }, -} - function isMissing(error: unknown): boolean { return error instanceof Error && 'code' in error && error.code === 'ENOENT' } @@ -45,7 +35,7 @@ function repositoryPath(root: string, path: string): string { } function parseConfig(configPath: string): ts.ParsedCommandLine { - const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) if (!parsed) throw new Error(`clean: cannot parse TypeScript config ${configPath}`) if (parsed.errors.length > 0) { throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) diff --git a/scripts/ts-project.ts b/scripts/ts-project.ts index 930c055d4f..9a0400a39d 100644 --- a/scripts/ts-project.ts +++ b/scripts/ts-project.ts @@ -11,7 +11,8 @@ interface ProjectGraph { options: ts.CompilerOptions } -const configHost: ts.ParseConfigFileHost = { +/** TypeScript config host shared by repository scripts. */ +export const repositoryConfigHost: ts.ParseConfigFileHost = { useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, readDirectory: (...args) => ts.sys.readDirectory(...args), fileExists: fileName => ts.sys.fileExists(fileName), @@ -52,7 +53,7 @@ function loadProjectGraph(projectRoot: string): ProjectGraph { /** Parse one config file and fail loud on any config diagnostic. */ function parseConfig(configPath: string): ts.ParsedCommandLine { - const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, configHost) + const parsed = ts.getParsedCommandLineOfConfigFile(configPath, {}, repositoryConfigHost) if (!parsed) throw new Error(`cannot parse TypeScript config ${configPath}`) if (parsed.errors.length > 0) { throw new Error(parsed.errors.map(error => ts.flattenDiagnosticMessageText(error.messageText, '\n')).join('\n')) From 1317e4809f1cedf404a68a54a629efe08dadfbc5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:30:38 +0800 Subject: [PATCH 55/70] fix(pty): recheck prompt handoff at idle boundary --- packages/pty/pty-local/README.md | 2 +- packages/pty/pty-local/src/session.ts | 7 +++++-- packages/pty/pty-local/tests/session.spec.ts | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/pty/pty-local/README.md b/packages/pty/pty-local/README.md index a2b3aec391..cd11509b43 100644 --- a/packages/pty/pty-local/README.md +++ b/packages/pty/pty-local/README.md @@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade. -Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. +Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate through one final poll after the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline. Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown. diff --git a/packages/pty/pty-local/src/session.ts b/packages/pty/pty-local/src/session.ts index 4b1faccc94..f05ad19e1d 100644 --- a/packages/pty/pty-local/src/session.ts +++ b/packages/pty/pty-local/src/session.ts @@ -331,8 +331,11 @@ export class LocalPtySession implements PtyBackendSession { // A prompt candidate can race bash's foreground handoff, but an interactive // child also inherits PROMPT_COMMAND. Silence therefore remains the bound // on waiting for shell ownership instead of letting a child marker suppress - // readiness until the absolute timeout. - if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) { + // readiness until the absolute timeout. One final poll lets a foreground + // handoff coincident with that boundary win before the fallback settles. + const idleFor = Date.now() - this.lastOutputAt + const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0 + if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) { this.settleActive('inferred_idle') return } diff --git a/packages/pty/pty-local/tests/session.spec.ts b/packages/pty/pty-local/tests/session.spec.ts index 4be21c79a6..3b76c9d71c 100644 --- a/packages/pty/pty-local/tests/session.spec.ts +++ b/packages/pty/pty-local/tests/session.spec.ts @@ -298,7 +298,7 @@ describe('LocalPtySession readiness and output', () => { void operation.done.then(() => { settled = true }) inspector.pgid = 789 terminal.emitData('\x1b]133;D;0\x07dsh> ') - await vi.advanceTimersByTimeAsync(40) + await vi.advanceTimersByTimeAsync(50) expect(settled).toBe(false) inspector.pgid = 456 From f1577d620e0a43007f00d709f65c145b1a7533c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:33:46 +0800 Subject: [PATCH 56/70] test(web): harden replay scaffold lifecycle --- .../2026-07-20-gui-testing-system.i18n.yaml | 4 +- .../process/2026-07-20-gui-testing-system.md | 2 +- .../2026-07-20-gui-testing-system.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 24 +++--- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 24 +++--- apps/web/tests/replay-round-trip.e2e.ts | 13 ++- apps/web/tests/scaffold.ts | 82 ++++++++++++------- .../snapshots/fresh-round-trip/session.jsonl | 4 +- .../tests/snapshots/seeded-history/seed.jsonl | 4 +- packages/support/llm-replay/README.md | 2 +- scripts/doc-budgets.manifest.json | 2 +- 12 files changed, 97 insertions(+), 70 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml index ca443d7a16..ffa1e87103 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.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-gui-testing-system.md: b261bd2c84a628ab6fcdc29c59cf36a7b2428a76 -2026-07-20-gui-testing-system.zh.md: ecb8634695bd05359e6b590a825a4ad3604003b1 +2026-07-20-gui-testing-system.md: 546f65f065c0c2266773acc3c28b2833a094ba9b +2026-07-20-gui-testing-system.zh.md: 6601ae0a1c2bd1671af6f02961fbda81d30ab971 diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md index b261bd2c84..546f65f065 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.md @@ -20,7 +20,7 @@ Cut along the architecture's natural test seams into three tiers, bottom-up: |---|---|---|---| | 1 Protocol isomorphism | `AbstractApiClient` + `toFetchHandler` (bidirectional data / rpcId / zod types / SSE streams / batching / timeouts) | **The full chain at the isomorphic point**: `InProcessApiClient(toFetchHandler(脚本化 impl))` skips the network but genuinely runs the wire serialization — zero browser, pure node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 Object-layer orchestration | `Session`/`SessionManager`/`ConnectionController` (state machines and timing: stitching / dedup / paging / optimistic draft clearing / pendingBuffers / reconnect / backoff) | **The "event sequence in → snapshot out" golden path**: programmable fakes + deferreds controlling timing + fake timers controlling backoff | `packages/client/{runtime,connection}/tests/` | -| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane replays recorded session fixtures through the real in-process web assembly (`llm: false` + dsh-llm-replay) against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 Assembled presentation | Built artifacts × the real client loader and plugin composition | App-owned semantic snapshots boot all eight built client plugins under jsdom for deterministic cross-plugin state changes; bare Playwright smoke separately proves the real browser/carrier boundary, with real-host cases self-skipping without a key; the keyless browser e2e lane disables the shipped model-adapter row and replays recorded session fixtures through `dsh-llm-replay` in the real in-process web assembly against conversation aria goldens ([web e2e lane](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`, `apps/web/tests/smoke-{fixture,real}.e2e.ts`, `apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | Inter-tier discipline: **each tier tests its own layer, upper tiers never re-test lower ones** — an app semantic snapshot pins only user-visible projection across the assembled plugin boundary, while Playwright smoke proves browser and carrier liveness; wire semantics belong to tier 1 and data semantics to tier 2. Pure-function layers (lineage/partial/notifier/fold-adapter) are tested directly with zero fakes in the same package's tests/ alongside tier 2. diff --git a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md index ecb8634695..6601ae0a1c 100644 --- a/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md +++ b/.agents/notes/implemented/process/2026-07-20-gui-testing-system.zh.md @@ -20,7 +20,7 @@ GUI 栈需要考虑多种应用形态,同应用形态内的不同运行环境 |---|---|---|---| | 1 协议同构层 | `AbstractApiClient` + `toFetchHandler`(双向数据/rpcId/ZOD类型/SSE 流/合批/超时) | **同构点全链**:`InProcessApiClient(toFetchHandler(脚本化 impl))` 不过网络但真跑 wire 序列化——零浏览器、纯 node env | `packages/host/apiproxy/tests/client-handler.spec.ts` | | 2 对象层编排 | `Session`/`SessionManager`/`ConnectionController`(状态机与时序:缝合/去重/翻页/乐观清稿/pendingBuffers/重连/退避) | **「事件序列进→快照出」黄金路径**:可编程假体 + deferred 控时序 + fake timers 控退避 | `packages/client/{runtime,connection}/tests/` | -| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道把录制的会话 fixture 通过真实进程内 web 组装(`llm: false` + dsh-llm-replay)回放,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | +| 3 组装呈现层 | 构建产物 × 真实 client loader 与插件组合 | 归应用所有的语义快照会在 jsdom 下启动全部 8 个已构建的 client 插件,以固定确定性的跨插件状态变化;独立使用 Playwright 裸库的冒烟测试负责验证真实浏览器/承载层边界,真 host 用例在无密钥时自行跳过;无密钥浏览器 e2e 车道会禁用交付配置中的模型适配器行,并通过 `dsh-llm-replay` 在真实进程内 web 组装中回放录制的会话 fixture,与会话区 aria 期望输出比对([web e2e 车道](../testing/2026-07-24-web-gui-browser-e2e-lane.md)) | `apps/web/tests/*.snapshot.ts`、`apps/web/tests/smoke-{fixture,real}.e2e.ts`、`apps/web/tests/{replay-round-trip,seeded-history}.e2e.ts` | 层间纪律:**下层各测各的,上层不重测下层**:应用语义快照只固定组装后插件边界上的用户可见投影,Playwright 冒烟测试负责验证浏览器与承载层是否存活;wire 语义归 1 层,数据语义归 2 层。纯函数层(lineage/partial/notifier/fold-adapter)随 2 层同包 tests/ 零假体直测。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index e50541fa85..ef389fecb3 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.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-web-gui-browser-e2e-lane.md: b6e62f59e12c64dd5386eaaabe15e863ef52e291 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 9f806c7030336bad4f7a9ca7695a03982d8a7878 +2026-07-24-web-gui-browser-e2e-lane.md: fb870c4bb2c85d8be7ac11f9f29f05bf24f446a4 +2026-07-24-web-gui-browser-e2e-lane.zh.md: c43e526d27fd4d8cf4f774e8a03480930682041d diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index b6e62f59e1..fb870c4bb2 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -6,7 +6,7 @@ English | [中文](2026-07-24-web-gui-browser-e2e-lane.zh.md) ## Problem -The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → `bootHost`'s agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. +The web GUI ships as a real assembled chain — chromium page → client plugin bundles → HTTP unary RPC + two SSE streams → `toFetchHandler`/apiproxy → the host agent loop, tools, and JSONL persistence — and no test exercised that chain keylessly and deterministically. The [GUI testing system](../process/2026-07-20-gui-testing-system.md) covers tier 1 (wire isomorphism in node), tier 2 (object-layer state machines), and tier-3 smokes, but the keyless smoke drives `FixtureApiClient` — no host, no wire, no agent loop — while the full-chain smoke needs `DEEPSEEK_API_KEY` and a live model, so it is nondeterministic and self-skips in keyless CI. The snapshot philosophy of [docs/testing.md](../../../../docs/testing.md) — record once with a key, replay forever keyless, refresh on format churn — already covers the ACP, headless `stream-json`, and TUI transcript surfaces; the web surface was the one assembled product shape without it. The gap is exactly where the two confirmed GUI P0s hid: the wire carriage chain the fixture client short-circuits. ## Decision @@ -16,33 +16,33 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition: the shipped `apps/cli/cordis.yml` through the vendored Loader's include boot — the same tree and mechanism `AppCLIEntry` drives for `dsh web` (the config-tree boot landed upstream on 2026-07-25, superseding this lane's earlier in-process `startHost` assembly and resolving the original Loader-ization question in favor of Loader-izing). Divergences ride include patches over the SAME shipped tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. -Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. (The first round's `BootHostOptions.llm: 'deepseek' | false` seam was superseded by the config-tree boot and removed with `bootHost`'s web role.) +Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelContext` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. `seedSession()` seeds cold sessions through the real persistence API — a throwaway `Context` mounting `SessionStore` + `SessionPersistenceJsonl` against the host's root, `create()` + `append()`, one `utimes` backdate for deterministic sidebar order (the `semantic-checkpoint.snapshot.ts` precedent) — never raw file writes, so the seeder knows nothing of bucket hashing, filename encoding, or compression, and the host's zstd default needs no boot knob. Seeds are validated at seed time (parseable, ending in `turn/end` — an open final turn would be mutated by resume's crash repair). ### Determinism rules -The barrier stack for a prompted turn, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible); (3) any log harvest after `host.dispose()`. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open). +The barrier stack for replay-mode browser assertions is, in order: (1) host-side `await agent.whenIdle()` under a timeout, keyed off the in-process `turn/end` — the idle flip follows the persistence flush, so one await covers turn completion and durability; (2) browser settled poll (streaming detached, final text visible). Record-mode log harvest runs after `whenIdle()` and before scaffold disposal while the live session remains available. An in-process `turn/end` listener alone is a wrong barrier (it fires before the SSE frame reaches the browser and before the fsync); file polling is banned (slow on NFS, superseded by `whenIdle`); `networkidle` is banned outright (never resolves while an SSE stream is open). No single-shot transient-DOM assertions: every hop from replay yield to React commit can coalesce chunks, so sampling `[data-streaming]` is a race by construction. Streaming incrementality is asserted from the persisted `assistant/chunk` events (model-visible ⟺ logged makes the log the authoritative proof). `dsh-llm-replay`'s opt-in `paceMs` (default absent = burst) is a realism knob so the browser observes genuinely incremental SSE; correctness never leans on it, and abort during a pace wait cancels promptly. -Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; selectors anchor on roles, `data-*` attributes, and visible text. +Every scenario fails on any pageerror and on the client's connection-loss/gap-repair console warnings: the reconnect machine plus history resync would otherwise self-heal a dead SSE path and the suite would certify a broken wire. Scaffold `close()` calls the `ReplayHandle.assertConsumed()` teardown check (every recorded script bound, every cursor drained), converting silent underruns and shifted bindings into crisp diagnostics. No vitest retry on the lane; one chromium per file, fresh context per scenario, one host per scenario; viewport pinned; interaction selectors anchor on roles, `data-*` attributes, and visible text, while the frame and conversation-region captures use the existing CSS-module local-name anchors. ### Expected outputs -One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride `host.ctx` session events inline (which tools ran, `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. +One committed golden per scenario: a normalized `ariaSnapshot()` of the conversation region (`ui.expected.md`) — uuid/cwd/workspace-basename/duration tokens normalized, captured poll-until-equal at the settled milestone — plus a few role/text anchor assertions that stay green under a semantics-preserving component rewrite while the golden churns reviewably. The aria tree is the mechanization of the client rule "assert what the user would see, never class names". World-state assertions ride root-context session events inline (which tool call produced which durable result, whether `turn/end` completed) instead of a second committed log golden: the persisted-log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence, and re-pinning it here would double refresh cost against the tier discipline. `refresh` is the sole golden writer — a missing golden in replay mode fails with the healing command rather than self-bootstrapping. -The typecheck plane split is structural: `apps/web/tests/{scaffold,support,replay-round-trip.e2e,seeded-history.e2e}.ts` are host-plane programs (they boot the host spine), so they are excluded from the client-registered `apps/web` project and included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. +The typecheck plane split is structural: the three files that boot the host spine (`scaffold`, `replay-round-trip.e2e`, and `seeded-history.e2e`) are excluded from the client-registered `apps/web` project. Those files and their shared `support.ts` are included file-by-file in `tsconfig.host.json` — one program cannot hold both sides of the cordis `Context` merges. ### Modes and fixtures -`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. +`DSH_SNAPSHOT` selects replay (default, keyless), record (with key), or refresh (keyless) as inline spec branches — the TUI shape, not a suite factory: at two scenarios the acp-snapshot factory machinery has no owner, and the genuinely shared parts are already exported (`scrubRequestHeaders`, `parseSessionLog`, `installLlmReplay`). Each spec splits into drive steps (type, send, `whenTurnSettled` — run in all modes, never waiting on model-content selectors, so record cannot hang on a live model answering differently) and assertion steps (replay/refresh only). Record = drive live through the real composer + harvest the in-memory `session.header`/`session.events` (the TUI `rawSessionLog` shape — no file decompression) + `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` tokenization; a follow-up keyless refresh regenerates `ui.expected.md`. Both scenarios' fixtures were recorded against this assembly through this flow. A drift guard ties each spec's drive prompt to the fixture's recorded `user/message`. A fixture-inventory guard holds each scenario directory closed (exact file set, every JSONL a scrub fixed-point with no run-local `rpcId`). Web fixtures scrub headers everywhere and pin no header class, following the TUI precedent over the strict [pinned-header](2026-07-06-pin-request-header-content-in-one-scenario.md) reading — see Deferred. ### Scenarios -1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (bash `tool/call`, completed `turn/end`, >10 chunk events). +1. **`replay-round-trip`** — new session, prompt through the real composer, replay streams reasoning + a `bash` tool call that really executes in the temp workspace + final text (paced 15ms). Asserts settled markdown, the aria golden, and inline world state (the bash call's durable result is exactly `WEB_E2E_OK\n`, completed `turn/end`, >10 chunk events). 2. **`seeded-history`** — a recorded session seeded cold; the sidebar lists it (group row → session row, collapsed by default), opening renders tool cards and text purely from the log through the implicit cold-resume attach inside `session.history` — zero model calls in replay, so no binding constraints; record mode drives the same turn live (real `read` tool against seeded workspace files) to produce the seed. ### CI stance @@ -65,9 +65,9 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A `packages/support/web-snapshot` package with a `defineWebSnapshotSuite` factory.** Rejected: chromium-driving source cannot honestly hold per-file 100% coverage on browserless coverage runners, and at two scenarios a factory generalizes from one consumer while the genuinely shared logic is already exported from gated packages. Re-entry trigger: a second web-shaped consumer or ≥6 scenarios with demonstrably drifting inline branches; the package boundary would then be drawn browser-free. -**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on `host.ctx` events keep the world-verification duty. +**A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-mode branch plus env plumbing in the product bin where the in-process route uses exported production functions; the bin's thin glue is covered by the keyless CLI smokes. Becomes free only if the web host is ever Loader-ized — declined in review, with the app-assembly ruling reaffirmed. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. @@ -81,7 +81,7 @@ The lane itself: `pnpm run test:web` runs both scenarios keylessly alongside the ## Deferred -- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins bootHost's composed prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. +- **Web header-class pin**: web fixtures tokenize `{{system}}`/`{{tools}}` everywhere and no scenario pins the web composition's prompt/tool schemas (`TODO(web-header-pin)` — the scaffold `recordFixture` JSDoc marks it). Following the TUI scrub-everywhere precedent; revisit when the web assembly's header diverges from the repl composition it mirrors. - **CI browser provisioning**: reversal of the no-browser-in-CI ruling, staged criteria above (`TODO(ci-browser)`). - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 9f806c7030..c43e526d27 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → `bootHost` 的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个文本记录(transcript)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。 +Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bundle → HTTP 单次 RPC + 两条 SSE(Server-Sent Events)流 → `toFetchHandler`/apiproxy → host 端的 agent loop(智能体循环)、工具与 JSONL 持久化——却没有任何测试无密钥且确定性地检验这条链。[GUI 测试体系](../process/2026-07-20-gui-testing-system.md)覆盖第 1 层(Node 中的协议同构)、第 2 层(对象层状态机)与第 3 层冒烟测试,但无密钥冒烟驱动的是 `FixtureApiClient`——没有 host、没有 wire、没有 agent loop——而全链路冒烟需要 `DEEPSEEK_API_KEY` 和真实模型,因此不确定、在无密钥 CI 中自行跳过。[docs/testing.md](../../../../docs/testing.md) 的快照哲学——带密钥录制一次、永久无密钥回放、格式变动时刷新——已覆盖 ACP(Agent Client Protocol)、headless `stream-json` 与 TUI 三个文本记录(transcript)表面;web 表面是唯一没有这层保障的组装形态。而缺口恰恰是两起已实证 GUI P0 藏身之处:fixture(测试前置数据)客户端短路掉的 wire 承载链。 ## 决策 @@ -16,33 +16,33 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 启动真实 web 组合:经 vendored Loader 的 include boot 加载交付的 `apps/cli/cordis.yml`——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制(配置树 boot 于 2026-07-25 在上游落地,取代了本车道第一轮的进程内 `startHost` 组装,也把当初的 Loader 化问题裁定为「Loader 化」)。差异全部经 include patch 骑在同一棵交付树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 -无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。(第一轮的 `BootHostOptions.llm: 'deepseek' | false` seam 已随配置树 boot 取代 `bootHost` 的 web 角色而移除。) +无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelContext` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 `seedSession()` 通过真实持久化 API 播种冷会话——一次性 `Context` 挂载 `SessionStore` + `SessionPersistenceJsonl` 指向 host 的根目录,`create()` + `append()`,一次 `utimes` 回拨保证侧栏顺序确定(`semantic-checkpoint.snapshot.ts` 先例)——绝不裸写文件,因此播种器对桶哈希、文件名编码、压缩一无所知,host 的 zstd 默认值也无需任何启动开关。种子在播种时即校验(可解析、以 `turn/end` 结尾——未闭合的最终轮次会被恢复(resume)的崩溃修复改写)。 ### 确定性规则 -提示一轮对话的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见);(3)任何日志采收都在 `host.dispose()` 之后。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。 +回放模式下浏览器断言的屏障栈,按序:(1)host 侧 `await agent.whenIdle()` 加超时,以进程内 `turn/end` 为锚——空闲翻转发生在持久化落盘之后,一次等待同时覆盖轮次完成与持久性;(2)浏览器安定轮询(流式输出节点已卸载、最终文本可见)。录制模式下,日志采收在 `whenIdle()` 之后、scaffold 释放之前进行,此时运行中的会话仍然可用。单独监听进程内 `turn/end` 是错误屏障(它先于 SSE 帧到达浏览器、先于 fsync 触发);文件轮询被禁止(NFS 上慢,且被 `whenIdle` 取代);`networkidle` 被彻底禁止(SSE 流保持打开时它永不解析)。 不做单次瞬态 DOM 断言:从回放产出到 React 提交的每一跳都可能合并分片,采样 `[data-streaming]` 天然就是竞态。流式输出的增量性由持久化的 `assistant/chunk` 事件断言(模型可见 ⟺ 已记录,使日志成为权威证据)。`dsh-llm-replay` 的可选 `paceMs`(默认缺省 = 突发)只是让浏览器观察到真正增量 SSE 的真实感旋钮;正确性绝不依赖它,且节奏等待期间中止会即时取消。 -每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;选择器只锚定 role、`data-*` 属性和可见文本。 +每个场景都会因任何 pageerror 或客户端的连接丢失/间隙修复控制台警告而失败:否则重连机制加历史重同步会把一条死掉的 SSE 通路自愈掉,套件反而认证了坏 wire。Scaffold 的 `close()` 调用 `ReplayHandle.assertConsumed()` 收尾检查(每个已录脚本都被绑定、每个游标都耗尽),把静默的少放与错绑变成清晰诊断。车道不设 vitest 重试;每文件一个 chromium、每场景一个新 context、每场景一个 host;视口固定;交互选择器锚定 role、`data-*` 属性和可见文本,而 frame 与会话区采集则使用既有的 CSS 模块局部类名锚点。 ### 预期输出 -每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在 `host.ctx` 会话事件上(哪些工具运行了、`turn/end` 完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 +每场景一份提交的预期输出:会话区规范化 `ariaSnapshot()`(`ui.expected.md`)——uuid/cwd/工作区目录名/时长归一为稳定 token,在安定里程碑处轮询至两次相等再采集——外加几条 role/文本锚断言,让保语义的组件重写在预期输出可评审地变动时仍保持绿色锚点。aria 树是 client 规则「断言用户所见,绝不断言类名」的机械化。世界状态断言内联在根上下文的会话事件上(哪次工具调用产生了哪项已持久化的工具结果、`turn/end` 是否完成)而不是第二份提交的日志预期输出:持久化日志表面已由 ACP/headless/TUI 套件经同一循环和持久化钉住,在此重复钉住会违背分层纪律、翻倍刷新成本。`refresh` 是预期输出的唯一写入者——回放模式下预期输出缺失会连同修复命令一起报错,而不是静默自举。 -类型检查平面切分是结构性的:`apps/web/tests/{scaffold,support,replay-round-trip.e2e,seeded-history.e2e}.ts` 是 host 平面程序(它们启动 host 主干),因此被排除出注册在 client 侧的 `apps/web` 工程,逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 +类型检查平面切分是结构性的:启动 host 主干的三个文件(`scaffold`、`replay-round-trip.e2e` 和 `seeded-history.e2e`)被排除出注册在 client 侧的 `apps/web` 工程。这三个文件及其共享的 `support.ts` 逐文件纳入 `tsconfig.host.json`——一个程序不能同时持有 cordis `Context` 合并的两侧。 ### 模式与 fixture -`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 +`DSH_SNAPSHOT` 以内联 spec 分支选择 replay(默认,无密钥)、record(带密钥)或 refresh(无密钥)——TUI 的形态,不是套件工厂:两个场景撑不起 acp-snapshot 工厂机制,且真正共享的部分已被导出(`scrubRequestHeaders`、`parseSessionLog`、`installLlmReplay`)。每个 spec 切分为驱动步骤(输入、发送、`whenTurnSettled`——所有模式都执行,绝不等待模型内容选择器,因此 record 不会因真实模型答法不同而挂起)与断言步骤(仅 replay/refresh)。Record = 经真实输入框实时驱动 + 采收内存中的 `session.header`/`session.events`(TUI 的 `rawSessionLog` 形态——无需文件解压)+ `scrubRequestHeaders` + `{{sessionId}}`/`{{cwd}}`/`{{rpcId}}` token 化;随后一次无密钥 refresh 重新生成 `ui.expected.md`。两个场景的 fixture 都经此流程对本组装录制。一条漂移防线把每个 spec 的驱动提示词与 fixture 录制的 `user/message` 绑定。fixture 清单防线保持每个场景目录封闭(精确文件集合,每个 JSONL 都是脱敏不动点,不含当次运行的 `rpcId`)。Web fixture 全部脱敏请求头且不钉任何头类别,沿用 TUI 先例而非[钉住请求头](2026-07-06-pin-request-header-content-in-one-scenario.md)的严格读法——见「暂缓」。 ### 场景 -1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(bash `tool/call`、完成的 `turn/end`、>10 个分片事件)。 +1. **`replay-round-trip`**——新会话,经真实输入框发送提示词,回放流式输出推理(reasoning)+ 一次在临时工作区真实执行的 `bash` 工具调用 + 最终文本(15ms 节奏)。断言安定后的 markdown、aria 预期输出与内联世界状态(这次 bash 调用的已持久化工具结果严格等于 `WEB_E2E_OK\n`、完成的 `turn/end`、>10 个分片事件)。 2. **`seeded-history`**——冷播种一份已录会话;侧栏列出它(分组行 → 会话行,默认折叠),打开后纯凭日志经 `session.history` 内的隐式冷恢复挂载渲染工具卡片与文本——replay 下零模型调用,因此没有任何绑定约束;record 模式实时驱动同一轮(真实 `read` 工具读取播种的工作区文件)来产出种子。 ### CI 立场 @@ -65,9 +65,9 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **`packages/support/web-snapshot` 包 + `defineWebSnapshotSuite` 工厂。** 已否决:驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100%,且两个场景就上工厂是从单一消费方过度泛化,真正共享的逻辑已从受门禁的包中导出。重启条件:出现第二个 web 形态消费方,或 ≥6 个场景的内联分支被证实各自漂移;届时包边界将画在无浏览器一侧。 -**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在 `host.ctx` 事件上的世界状态断言保住了验证世界的义务。 +**第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在产品 bin 里加测试模式分支和环境变量管道,而进程内路线用的是零产品改动的导出生产函数;bin 的薄胶水已由无密钥 CLI 冒烟覆盖。只有 web host 某天 Loader 化它才免费——评审中已否决,并重申了应用内组装的裁定。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 @@ -81,7 +81,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu ## 暂缓 -- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 bootHost 组装的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 +- **Web 头类别钉住**:web fixture 处处 token 化 `{{system}}`/`{{tools}}`,没有场景钉住 web 组合的提示词/工具 schema(`TODO(web-header-pin)`——scaffold 的 `recordFixture` JSDoc 有标记)。沿用 TUI 处处脱敏先例;当 web 组装的请求头与其镜像的 repl 组合进一步分叉时重审。 - **CI 浏览器供给**:推翻 CI 无浏览器裁定,分阶段标准见上(`TODO(ci-browser)`)。 - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 diff --git a/apps/web/tests/replay-round-trip.e2e.ts b/apps/web/tests/replay-round-trip.e2e.ts index 8baabde0ba..131f3fa5fd 100644 --- a/apps/web/tests/replay-round-trip.e2e.ts +++ b/apps/web/tests/replay-round-trip.e2e.ts @@ -80,9 +80,16 @@ describe('web e2e: fresh round trip through the real assembly', () => { // legal — the chunk-event assertions below carry incrementality. }) await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1) - // World state, not self-report: bash really ran and the turn closed clean. - const toolCalls = sessionEvents.filter(e => e.type === 'tool/call') - expect(toolCalls.map(e => (e as SessionEvent & { data: { name: string } }).data.name)).toContain('bash') + // World state, not self-report: the real bash executor returned the exact + // command output, and the turn closed cleanly. + const bashCall = sessionEvents.find(event => event.type === 'tool/call' && event.data.name === 'bash') + if (bashCall?.type !== 'tool/call') throw new Error('the replayed turn did not call the bash tool') + const bashResult = sessionEvents.find(event => + event.type === 'tool/result' && event.data.callId === bashCall.data.callId) + if (bashResult?.type !== 'tool/result') throw new Error('the bash tool call produced no durable result') + expect(bashResult.data.isError).toBe(false) + expect(bashResult.data.content.filter(block => block.type === 'text').map(block => block.text).join('')) + .toBe('WEB_E2E_OK\n') const turnEnds = sessionEvents.filter(e => e.type === 'turn/end') expect(turnEnds.length).toBe(1) expect((turnEnds[0] as SessionEvent & { data: { reason: { kind: string } } }).data.reason.kind).toBe('completed') diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index d858e0f7ad..babfbde919 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -105,6 +105,15 @@ export interface LaunchOptions { paceMs?: number } +/** Dispose the booted tree and remove both owned temp roots, reporting every independent cleanup failure. */ +async function cleanupScaffoldWorld(ctx: Context, workspaceCwd: string, persistenceRoot: string): Promise<unknown[]> { + const failures: unknown[] = [] + await Promise.resolve(ctx.fiber.dispose()).catch((error: unknown) => failures.push(error)) + await rm(workspaceCwd, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + await rm(persistenceRoot, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + return failures +} + /** * Boot the real web composition under the current snapshot mode. * @param options - replay fixture selection and pacing. @@ -120,7 +129,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We } } const workspaceCwd = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-ws-')) - const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) + let persistenceRoot: string + try { + persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-web-e2e-sessions-')) + } catch (error) { + const failures: unknown[] = [error] + await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError)) + if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') + throw error + } // The include patch set — the same mechanism AppCLIEntry and the ACP // snapshot overlay use, applied over the SAME shipped tree (a patch id that @@ -143,9 +160,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We // Sessions inherit the gateway's process.cwd() default; run the boot from // the temp workspace so tool cwd, session cwd, and fixtures agree. const originalCwd = process.cwd() - process.chdir(workspaceCwd) const ctx = new Context() + let port = 0 + let replayHandle: ReplayHandle | undefined try { + process.chdir(workspaceCwd) ctx.baseUrl = pathToFileURL(join(resolve(CONFIG_PATH), '..')).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include @@ -155,34 +174,34 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We }) await ctx.loader.await() assertEntriesLoaded(ctx, 'web e2e scaffold') + const boundPort = ctx.get('httpServer')?.port + if (boundPort === undefined) { + throw new Error('web e2e scaffold: httpServer service missing after settled boot') + } + port = boundPort + + // Fill the open llm seam on the settled root ctx (llm-deepseek is disabled + // in keyless modes; a scenario with no fixture leaves the seam empty so a + // stray stream fails loud with NO_ADAPTER). The direct install, unlike the + // plugin row, returns the ReplayHandle for the teardown consumption check. + if (mode !== 'record' && options.replayFixture !== undefined) { + replayHandle = installLlmReplay(ctx, { + file: options.replayFixture, + providers: REPLAY_PROVIDERS, + ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }), + }) + } } catch (error) { - process.chdir(originalCwd) - await ctx.fiber.dispose() - await rm(workspaceCwd, { recursive: true, force: true }).catch(() => undefined) - await rm(persistenceRoot, { recursive: true, force: true }).catch(() => undefined) + if (process.cwd() !== originalCwd) process.chdir(originalCwd) + const cleanupFailures = await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot) + if (cleanupFailures.length > 0) { + throw new AggregateError([error, ...cleanupFailures], 'web scaffold setup failed and cleanup was incomplete') + } throw error } finally { if (process.cwd() !== originalCwd) process.chdir(originalCwd) } - const port = ctx.get('httpServer')?.port - if (port === undefined) { - await ctx.fiber.dispose() - throw new Error('web e2e scaffold: httpServer service missing after settled boot') - } - // Fill the open llm seam on the settled root ctx (llm-deepseek is disabled - // in keyless modes; a scenario with no fixture leaves the seam empty so a - // stray stream fails loud with NO_ADAPTER). The direct install, unlike the - // plugin row, returns the ReplayHandle for the teardown consumption check. - let replayHandle: ReplayHandle | undefined - if (mode !== 'record' && options.replayFixture !== undefined) { - replayHandle = installLlmReplay(ctx, { - file: options.replayFixture, - providers: REPLAY_PROVIDERS, - ...(options.paceMs === undefined ? {} : { paceMs: options.paceMs }), - }) - } - return { mode, baseUrl: `http://127.0.0.1:${port}`, @@ -222,9 +241,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We } catch (error) { failures.push(error) } - await Promise.resolve(ctx.fiber.dispose()).catch((e: unknown) => failures.push(e)) - await rm(workspaceCwd, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) - await rm(persistenceRoot, { recursive: true, force: true }).catch((e: unknown) => failures.push(e)) + failures.push(...await cleanupScaffoldWorld(ctx, workspaceCwd, persistenceRoot)) if (failures.length > 0) throw new AggregateError(failures, 'web scaffold teardown failed') }, } @@ -247,9 +264,9 @@ function rawSessionLog(session: Session): string { * Record-mode fixture write-back: harvest the live session, scrub request * headers to {{system}}/{{tools}} (TODO(web-header-pin): the web lane pins no * header class — a deliberate deviation logged in the Agent Note's deferred - * work), tokenize the run-local session id and cwd ({{sessionId}}/{{cwd}}, - * the committed ACP fixture convention — re-records then diff only on real - * content), and write the committed fixture. + * work), tokenize the run-local session id, cwd, and browser RPC id + * ({{sessionId}}/{{cwd}}/{{rpcId}}, the committed fixture convention — + * re-records then diff only on real content), and write the fixture. * @param scaffold - the record-mode scaffold. * @param sessionId - the driven session. * @param fixturePath - the committed session.jsonl / seed.jsonl target. @@ -260,6 +277,7 @@ export async function recordFixture(scaffold: WebScaffold, sessionId: SessionId, const tokenized = scrubRequestHeaders(rawSessionLog(agent.session)) .split(sessionId).join('{{sessionId}}') .split(scaffold.workspaceCwd).join('{{cwd}}') + .replace(/"rpcId":"[^"]+"/g, '"rpcId":"{{rpcId}}"') await writeFile(fixturePath, tokenized) } @@ -389,7 +407,7 @@ export async function compareOrRefreshGolden(goldenPath: string, actual: string, /** * Fixture-inventory guard (the TUI afterAll shape): the scenario directory * holds exactly the expected files and every committed JSONL is a scrub - * fixed-point (no request-header bulk escaped the record write-back). + * fixed-point without a run-local browser RPC id. * @param dir - the scenario snapshot directory. * @param expected - the exact expected file inventory. */ @@ -399,6 +417,8 @@ export async function assertFixtureInventory(dir: string, expected: string[]): P for (const entry of entries.filter(name => name.endsWith('.jsonl'))) { const content = await readFile(join(dir, entry), 'utf8') expect(scrubRequestHeaders(content), `${dir}/${entry} carries request-header bulk`).toBe(content) + expect(content, `${dir}/${entry} carries a run-local rpcId`) + .not.toMatch(/"rpcId":"(?!\{\{rpcId\}\})[^"]+"/) } } diff --git a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl index 9bd1959879..21218b459d 100644 --- a/apps/web/tests/snapshots/fresh-round-trip/session.jsonl +++ b/apps/web/tests/snapshots/fresh-round-trip/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784973850091,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}}}} -{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"c4e068dc-c277-46ae-9713-6a2694027fb5"}},"surfaceOp":"append"} +{"type":"turn/start","seq":0,"time":1784973850102,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784973850103,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784973850105,"data":{"title":"Use the bash tool to","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1784973850164,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784973850165,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} diff --git a/apps/web/tests/snapshots/seeded-history/seed.jsonl b/apps/web/tests/snapshots/seeded-history/seed.jsonl index 0f61158a54..27e31004bc 100644 --- a/apps/web/tests/snapshots/seeded-history/seed.jsonl +++ b/apps/web/tests/snapshots/seeded-history/seed.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":1784974100747,"cwd":"{{cwd}}/workspace"} -{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}}}} -{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"f95c6f1c-f1b4-42bf-ba40-c05ae0647a70"}},"surfaceOp":"append"} +{"type":"turn/start","seq":0,"time":1784974100758,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1784974100759,"data":{"content":[{"type":"text","text":"Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"session/title","seq":2,"time":1784974100761,"data":{"title":"Use the read tool twice","messageSeqs":[1],"source":{"kind":"fallback"}}} {"type":"step/start","seq":3,"time":1784974100827,"data":{"turn":1,"step":1}} {"type":"request/header","seq":4,"time":1784974100828,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index a8d811f35b..534d289b19 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -2,7 +2,7 @@ A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery. -Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. +Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus the web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the web lane installs it directly to retain the teardown consumption handle. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`. ## How the fixture works diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index f6f8a5c3fc..c2fee63c70 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -4,7 +4,7 @@ "docs/architecture.md": 1800, "docs/cordis-primer.md": 600, "docs/defensive-patterns.md": 550, - "docs/testing.md": 1040, + "docs/testing.md": 1100, "examples/AGENTS.md": 310, "packages/AGENTS.md": 660, "packages/README.md": 790 From ea8b1178cda98cc945a016d75bd3e4191b35e704 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:02:46 +0800 Subject: [PATCH 57/70] feat(web): session list one-list, hover card, row menus, rename, manual ordering Sidebar session list grows the figma 239-10458 feature set and the workspace/session browsing region moves wholesale into ui-workspace: - Group-by menu (WorkSpace / In one list): flat mode lists every session top-level, strictly newest-first; the choice persists across reloads. - Session rows get a 500ms hover detail card (title / relative time / status line) and a ... menu (Rename / Fork session / Delete session, visual-only for now); workspace headers get ... with Rename (wired) and Delete workspace (visual-only). - workspace.rename RPC: trims, rejects duplicate titles on the create chain (workspace-name-conflict), no-op on same title; modal dialog with client-side duplicate pre-check. - workspace.insertSessionBefore RPC (DOM-insertBefore semantics, omitted anchor appends): HTML5 drag reorder of root sessions inside a workspace group; order truth stays host-side, the view refreshes from the response/changed frame. - Activity pinning removed: the session/event touchSession chain is gone; workspace accounts are manually owned (new sessions prepend, explicit reordering only). Contracts and tests updated, api catalog regenerated. - ui-sidebar reduced to the column shell (brand, fold state machine, New Session, Settings) exposing one sidebar.workspaces hole with a two-fact owner share {wide, expandSidebar}; ui-workspace owns the whole region (header, search, grouped/flat lists, dialogs, drag) plus the picker via a shared WorkspaceCreateFlow. The old sidebar.workspace picker slot and its deferral indirection are gone. - ui-primitives: Menu gains label entries, danger rows, and closeOnPointerLeave; new HoverCard (portaled, open-delay, disabled guard). Hover card and row menu never coexist. --- docs/cordis-catalog/services.md | 11 - docs/event-producer-consumer.md | 2 +- .../client/connection/src/client/fixture.ts | 55 +++ packages/client/connection/tests/fake-api.ts | 6 + .../runtime/src/client/workspaces/manager.ts | 41 +- .../runtime/src/client/workspaces/service.ts | 31 +- packages/client/runtime/tests/fake-api.ts | 9 + .../ui-primitives/src/HoverCard.module.css | 22 + .../client/ui-primitives/src/HoverCard.tsx | 109 +++++ .../client/ui-primitives/src/Menu.module.css | 21 + packages/client/ui-primitives/src/Menu.tsx | 33 +- packages/client/ui-primitives/src/index.ts | 3 +- .../client/ui-sidebar/src/client/Rows.tsx | 143 ------ .../src/client/SidebarRoot.module.css | 186 +------- .../ui-sidebar/src/client/SidebarRoot.tsx | 248 +--------- .../ui-sidebar/src/client/contract/slots.ts | 59 +-- .../client/ui-sidebar/src/client/index.ts | 15 +- .../client/ui-sidebar/tests/apply.spec.tsx | 18 +- .../ui-sidebar/tests/sidebar-root.spec.tsx | 274 ++--------- packages/client/ui-workspace/package.json | 3 + .../src/client/WorkspaceBrowser.module.css | 265 +++++++++++ .../src/client/WorkspaceBrowser.tsx | 428 ++++++++++++++++++ .../src/client/WorkspacePicker.tsx | 63 ++- .../ui-workspace/src/client/contract/slots.ts | 63 ++- .../client/ui-workspace/src/client/index.ts | 95 ++-- .../src/client/rows}/Rows.module.css | 53 ++- .../ui-workspace/src/client/rows/Rows.tsx | 285 ++++++++++++ .../client/ui-workspace/src/client/stores.ts | 36 ++ .../src/client/tree.ts | 33 +- .../client/ui-workspace/tests/apply.spec.ts | 68 +-- .../tests/rows.spec.tsx | 4 +- .../tests/tree.spec.ts | 0 .../cordis/tool-cordis/src/api-catalog.ts | 6 +- packages/host/apiproxy/src/api-proxy.ts | 69 ++- packages/host/apiproxy/src/api/rpc-map.ts | 2 + packages/host/apiproxy/src/api/rpc.schema.ts | 1 + packages/host/apiproxy/src/api/rpc.ts | 1 + .../host/apiproxy/src/api/workspace.schema.ts | 26 ++ packages/host/apiproxy/src/api/workspace.ts | 28 +- packages/host/apiproxy/src/fetch/client.ts | 8 + packages/host/apiproxy/src/fetch/handler.ts | 4 + .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 12 + packages/workspace/workspace/src/entity.ts | 56 ++- packages/workspace/workspace/src/index.ts | 37 +- packages/workspace/workspace/src/types.ts | 25 +- .../workspace/tests/workspace.spec.ts | 118 +---- pnpm-lock.yaml | 4 + 48 files changed, 1948 insertions(+), 1133 deletions(-) create mode 100644 packages/client/ui-primitives/src/HoverCard.module.css create mode 100644 packages/client/ui-primitives/src/HoverCard.tsx delete mode 100644 packages/client/ui-sidebar/src/client/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css create mode 100644 packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx rename packages/client/{ui-sidebar/src/client => ui-workspace/src/client/rows}/Rows.module.css (79%) create mode 100644 packages/client/ui-workspace/src/client/rows/Rows.tsx create mode 100644 packages/client/ui-workspace/src/client/stores.ts rename packages/client/{ui-sidebar => ui-workspace}/src/client/tree.ts (89%) rename packages/client/{ui-sidebar => ui-workspace}/tests/rows.spec.tsx (97%) rename packages/client/{ui-sidebar => ui-workspace}/tests/tree.spec.ts (100%) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 2e30e8602b..474f587230 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1981,15 +1981,6 @@ get(id: WorkspaceId): Workspace | undefined */ list(): Workspace[] -/** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ -async touchSession(sessionId: SessionId): Promise<void> - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned @@ -2000,8 +1991,6 @@ async touchSession(sessionId: SessionId): Promise<void> async resolveByPath(path: string): Promise<Workspace | undefined> ``` -Types: [SessionId](../core-data-structures/core.md) - Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d9521f7d67..7d1e6b691a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -36,7 +36,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:52`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`user-approval`](../packages/ui/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:89`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace`](../packages/workspace/workspace), [`workspace-context`](../packages/context/workspace-context) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:101`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:111`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc), [`subagent`](../packages/subagent/subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:113`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 53c18dca5c..d6f3fa61de 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) return ok(request, { workspace: { ...created }, created: true }) }, + rename: (request) => { + const { workspaceId, title } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + const trimmed = title.trim() + if (trimmed !== workspace.title) { + if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { + return err(request, { + code: 'workspace-name-conflict', + message: `workspace name '${trimmed}' is already in use`, + details: { name: trimmed }, + }) + } + workspace.title = trimmed + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, + insertSessionBefore: (request) => { + const { workspaceId, sessionId, beforeSessionId } = request.payload + const workspace = workspaces.find(w => w.workspaceId === workspaceId) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `no workspace ${workspaceId}`, + details: { workspaceId }, + }) + } + if (!workspace.sessionIds.includes(sessionId) + || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { + return err(request, { + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${workspaceId}`, + details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, + }) + } + const without = workspace.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) + } + return ok(request, { workspace: { ...workspace } }) + }, }, events: { async *mux(_request, signal) { @@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient { case 'host.describe': return this.api.host.describe(request) case 'workspace.list': return this.api.workspace.list(request) case 'workspace.create': return this.api.workspace.create(request) + case 'workspace.rename': return this.api.workspace.rename(request) + case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) } } diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index faca82d5c3..eecacc9581 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient { workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true, }))), + rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), + insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({ + workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, + }))), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index 6db4e54c79..c512694816 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -1,7 +1,7 @@ /** Workspace baseline, incremental-frame, and unary-action owner. */ import type { - HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView, + HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' import { mergeOrderedBaseline } from '../ordered-baseline.ts' @@ -143,6 +143,40 @@ export class WorkspaceManager { return result } + /** + * Rename a Workspace, then publish its returned snapshot without waiting + * for the changed frame. + * @param workspaceId - target workspace. + * @param title - new display title. + * @returns the wire result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> { + const { result } = await this.api.workspace.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Move a session within its Workspace's manual order, then publish the + * returned snapshot without waiting for the changed frame. + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the wire result. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise<RpcResult<{ workspace: WorkspaceView }>> { + const { result } = await this.api.workspace.insertSessionBefore({ + workspaceId, sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + /** * Host-frame entry. Non-workspace frames are ignored so the runtime can * fan one host stream out to both object managers. @@ -189,6 +223,11 @@ export class WorkspaceManager { private upsert(view: WorkspaceView, identity?: Workspace): void { this.refreshFrames?.push(view) const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId) + // Mutation responses and changed frames race (two carriers, no ordering): + // reject a snapshot strictly older than the installed projection so a + // late unary response cannot roll back a newer frame. + const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return if (identity !== undefined) { this.items = index === -1 ? [identity, ...this.items] diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 854c53a75f..9768a3fac2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -2,7 +2,7 @@ import type { Context } from 'cordis' import type { - IApiClient, RpcError, WorkspaceId, WorkspaceView, + IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-connection/client' import type { SnapshotStore } from '../contract/store.ts' import { createSnapshotStore } from '../contract/store.ts' @@ -100,6 +100,35 @@ export class WorkspacesService { return result.value.workspace } + /** + * Rename a Workspace. + * @param workspaceId - target workspace. + * @param title - new display title (trimmed non-empty by the Host). + * @returns the renamed Workspace view. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> { + const result = await this.manager.rename(workspaceId, title) + if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + + /** + * Move a session within its Workspace's manual order (DOM-insertBefore-like). + * @param workspaceId - owning workspace. + * @param sessionId - accounted session to move. + * @param beforeSessionId - accounted anchor to insert before; omitted appends. + * @returns the updated Workspace view. + */ + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise<WorkspaceView> { + const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`) + return result.value.workspace + } + /** * Refresh the workspace baseline, reusing an in-flight pull. * @returns completion of the current or newly started workspace baseline pull. diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 45efcf9e36..a9fbda4907 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient { onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> = () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true })) + onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> = + () => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') })) + readonly workspace: IApiClient['workspace'] = { list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)), create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + insertSessionBefore: (payload: unknown) => + this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)), } /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css new file mode 100644 index 0000000000..8d8a52100e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -0,0 +1,22 @@ +/* Block, not inline-flex: consumers wrap full-width list rows and an + * inline wrapper would shrink them; the card still measures this rect. */ +.root { + position: relative; + display: block; +} + +/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the + * menu card's elevation. Surface is #2C2C2E in both themes (figma value, + * light/dark identical), so a component-level variable, not a theme token. */ +.card { + --dsw-hovercard-bg: #2C2C2E; + position: fixed; + z-index: 100; + box-sizing: border-box; + width: 244px; + padding: 12px 16px; + border-radius: 12px; + background: var(--dsw-hovercard-bg); + box-shadow: var(--dsw-shadow-lv3); + pointer-events: none; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx new file mode 100644 index 0000000000..58e281778e --- /dev/null +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -0,0 +1,109 @@ +// HoverCard: delayed hover-preview card portaled to document.body. +// Same portal mechanics as Menu: the wrapper span supplies the anchor rect, +// the card is fixed-positioned at its right edge and repositions on +// scroll/resize while open. Display-only — the card ignores pointer events +// and closes the instant the pointer leaves the anchor (no close delay). + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { createPortal } from 'react-dom' +import css from './HoverCard.module.css' + +/** + * Render an anchor with a hover-triggered preview card. + * @param props.anchor - the hover target (rendered in place inside a wrapper span). + * @param props.content - card content (display-only, no pointer interaction). + * @param props.openDelayMs - hover dwell before the card shows (default 500). + * @param props.disabled - suppress opening; turning true closes an open card. + * @returns anchor wrapper with the conditional portaled card. + */ +export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: { + anchor: ReactNode + content: ReactNode + openDelayMs?: number + disabled?: boolean +}) { + const rootRef = useRef<HTMLSpanElement>(null) + const cardRef = useRef<HTMLDivElement>(null) + const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) + const [open, setOpen] = useState(false) + const [pos, setPos] = useState<CSSProperties | null>(null) + + const clearTimer = () => { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } + } + + // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!disabled) return + clearTimer() + setOpen(false) + }, [disabled]) + + useEffect(() => clearTimer, []) + + // Fixed-position from the anchor rect before paint; track the anchor while + // open (capture-phase scroll catches nested panes), as in Menu portal mode. + useLayoutEffect(() => { + if (!open) { setPos(null); return } + const place = () => { + const r = rootRef.current?.getBoundingClientRect() ?? null + if (r === null) return + const h = cardRef.current?.offsetHeight ?? 0 + const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top + setPos({ left: r.right + 8, top }) + } + place() + window.addEventListener('scroll', place, true) + window.addEventListener('resize', place) + return () => { + window.removeEventListener('scroll', place, true) + window.removeEventListener('resize', place) + } + }, [open]) + + // The first placement ran before the card mounted (height read 0): once the + // card's real height is measurable, correct the bottom-edge clamp. + useLayoutEffect(() => { + if (!open || pos === null || typeof pos.top !== 'number') return + const h = cardRef.current?.offsetHeight ?? 0 + if (pos.top + h > window.innerHeight - 8) { + const top = window.innerHeight - h - 8 + if (pos.top !== top) setPos({ ...pos, top }) + } + }, [open, pos]) + + const card = open && pos !== null && ( + <div ref={cardRef} className={css.card} style={pos}> + {content} + </div> + ) + + return ( + <span + ref={rootRef} + className={css.root} + onPointerEnter={() => { + if (disabled) return + clearTimer() + timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs) + }} + onPointerLeave={() => { + clearTimer() + setOpen(false) + }} + // Any press inside the anchor (row click, menu trigger) dismisses the + // card immediately, without waiting for the owner to flip `disabled`. + onPointerDownCapture={() => { + clearTimer() + setOpen(false) + }} + > + {anchor} + {card !== false && createPortal(card, document.body)} + </span> + ) +} diff --git a/packages/client/ui-primitives/src/Menu.module.css b/packages/client/ui-primitives/src/Menu.module.css index b55abe094d..28c3150335 100644 --- a/packages/client/ui-primitives/src/Menu.module.css +++ b/packages/client/ui-primitives/src/Menu.module.css @@ -109,6 +109,27 @@ background: transparent; } +/* Destructive row: error text/icon, danger hover fill. */ +.danger { + color: var(--dsw-alias-state-error-primary); +} + +.danger .itemIcon { + color: var(--dsw-alias-state-error-primary); +} + +.danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +/* Heading row: non-interactive small grey text, padding aligned with items. */ +.label { + padding: 8px 10px; + font-size: 12px; + line-height: 16px; + color: var(--dsw-alias-label-tertiary); +} + /* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */ .separator { height: 1px; diff --git a/packages/client/ui-primitives/src/Menu.tsx b/packages/client/ui-primitives/src/Menu.tsx index de015ee534..9abb6a3bb2 100644 --- a/packages/client/ui-primitives/src/Menu.tsx +++ b/packages/client/ui-primitives/src/Menu.tsx @@ -4,6 +4,7 @@ // the anchor rect, for anchors inside overflow-clipping containers (sidebar). // The owner controls `open`; outside-click closing uses one document listener // active only while open. Submenus open on hover/focus inside the same root. +// Entries also cover non-interactive `label` headings and `danger` rows. import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' @@ -19,6 +20,8 @@ export interface MenuItem { disabled?: boolean /** Leading icon (figma .Menu_cell gap 8). */ icon?: ReactNode + /** Destructive row: error-colored text/icon and danger hover fill. */ + danger?: boolean /** Nested card opened to the right on hover/focus. */ submenu?: readonly MenuItem[] } @@ -29,13 +32,24 @@ export interface MenuSeparator { id: string } -/** One primary-menu entry: a row or a separator. */ -export type MenuEntry = MenuItem | MenuSeparator +/** Non-interactive heading row above a group of items. */ +export interface MenuLabel { + type: 'label' + id: string + text: string +} + +/** One primary-menu entry: a row, a separator, or a heading label. */ +export type MenuEntry = MenuItem | MenuSeparator | MenuLabel function isSeparator(entry: MenuEntry): entry is MenuSeparator { return 'type' in entry && entry.type === 'separator' } +function isLabel(entry: MenuEntry): entry is MenuLabel { + return 'type' in entry && entry.type === 'label' +} + /** * Render an anchored dropdown menu. * @param props.open - whether the list is showing (owner-controlled). @@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * from the anchor rect (repositions on scroll/resize while open). Use when an * ancestor's overflow clipping would crop the in-place list; default false * keeps the pure-CSS in-place behavior. + * @param props.closeOnPointerLeave - close the list when the pointer leaves + * it (default false keeps it open until outside click/Escape/selection). * @param props.getAnchorRect - portal mode only: supply the anchor rect * directly (e.g. from a host-owned trigger button) instead of measuring the * Menu's own wrapper span. Required when the wrapper isn't itself laid out at @@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator { * scroll/resize; return null to skip placement for that frame. * @returns anchor wrapper with the conditional list. */ -export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: { +export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: { open: boolean anchor: ReactNode items: readonly MenuEntry[] @@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align align?: 'start' | 'end' side?: 'bottom' | 'top' portal?: boolean + closeOnPointerLeave?: boolean getAnchorRect?: () => DOMRect | null className?: string }) { @@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)} style={fixedPos ?? undefined} role="menu" + onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined} + // React portals bubble synthetic events through the REACT tree: without + // this stop, an item click re-fires the anchor row's own onClick + // (open/toggle) after onSelect. + onClick={(e) => { e.stopPropagation() }} > {items.map(entry => { if (isSeparator(entry)) { return <div key={entry.id} className={css.separator} role="separator" /> } + if (isLabel(entry)) { + return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div> + } const hasSub = entry.submenu !== undefined && entry.submenu.length > 0 const subOpen = hasSub && openSubmenuId === entry.id return ( @@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align <button type="button" role="menuitem" - className={clsx(css.item, entry.id === selectedId && css.selected)} + className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)} disabled={entry.disabled} aria-haspopup={hasSub ? 'menu' : undefined} aria-expanded={hasSub ? subOpen : undefined} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 9fd3d149fc..5eff2e40b2 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx' export { Pill } from './Pill.tsx' export { Input } from './Input.tsx' export { Menu } from './Menu.tsx' -export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx' +export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx' +export { HoverCard } from './HoverCard.tsx' export { Modal } from './Modal.tsx' export { ConnectionBanner } from './ConnectionBanner.tsx' export { FishLogo } from './FishLogo.tsx' diff --git a/packages/client/ui-sidebar/src/client/Rows.tsx b/packages/client/ui-sidebar/src/client/Rows.tsx deleted file mode 100644 index 6535a9a08a..0000000000 --- a/packages/client/ui-sidebar/src/client/Rows.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Sidebar tree row components (figma Cell set 14:3080): pure presentational — - * all data and callbacks arrive via props. Hover swaps (folder->chevron, - * time->ellipsis, action buttons) are CSS-only. - */ -import clsx from 'clsx' -import { - IconFolderClose16, IconFolderOpen16, IconPlusOutline16, - IconTriangleRightFill14, StateDot, -} from '@deepseek-ai/dsh-client-ui-primitives' -import type { GroupNode, SessionNode } from './tree.ts' -import { formatRelativeTime } from './tree.ts' -import css from './Rows.module.css' - -/** Indent step per tree level: one 16px slot (figma session cell). */ -const INDENT_STEP = 16 - -/** - * Project (workspace) header row: 54px, folder + title + session count; - * hover reveals the chevron and create button. `containsCurrent` arrives on - * the node (derivation fact, no renderer scan). - * @param props.group - derived group node. - * @param props.onToggle - expand/collapse the group. - * @param props.onCreate - start a frontend Session inside this Workspace. - * @returns the row element. - */ -export function ProjectRowItem({ group, onToggle, onCreate }: { - group: GroupNode - onToggle: () => void - onCreate: () => void -}) { - const row = group - const active = group.expanded && group.containsCurrent - const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` - return ( - <div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}> - <span className={clsx(css.slot, css.folder, active && css.folderActive)}> - {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />} - </span> - <span className={clsx(css.slot, css.chevron)}> - <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> - </span> - <span className={css.projectText}> - <span className={css.title}>{row.label}</span> - <span className={css.meta}>{count}</span> - </span> - <span className={css.rowActions}> - <button - type="button" - className={css.iconButton} - aria-label={`New session in ${row.label}`} - onClick={(e) => { e.stopPropagation(); onCreate() }} - > - <IconPlusOutline16 /> - </button> - </span> - </div> - ) -} - -/** - * The selected "New session" row for a frontend Session Intent targeted to a - * real Workspace. The row disappears when the Intent is replaced or connects. - * @returns the placeholder row element. - */ -export function IntentRowItem() { - return ( - <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}> - <span className={css.slot} /> - <span className={css.slot} /> - <span className={css.title}>New session</span> - </div> - ) -} - -/** - * One session subtree: the node's own 34px row (indent by depth, expand - * twist when it has children, running dot, relative time) plus its visible - * children, recursively — the component tree mirrors the derived tree. - * @param props.node - derived session node. - * @param props.depth - 0 = directly under the group header. - * @param props.currentId - selected session id (row highlight). - * @param props.now - epoch ms for relative-time formatting. - * @param props.onOpen - open a session by id. - * @param props.onToggle - unfold/fold a subtree by id. - * @returns the node's row followed by its children. - */ -export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: { - node: SessionNode - depth: number - currentId: string | undefined - now: number - onOpen: (id: SessionNode['id']) => void - onToggle: (id: SessionNode['id']) => void -}) { - const row = node - const selected = node.id === currentId - // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to - // the title): both slots are always reserved so titles align whether or not - // the twist/dot is lit. Extra depth rides the left padding. - const ownRow = ( - <div - className={clsx(css.sessionRow, selected && css.selected)} - role="treeitem" - aria-selected={selected} - {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})} - style={{ paddingLeft: 8 + depth * INDENT_STEP }} - onClick={() => { onOpen(node.id) }} - > - {row.hasChildren - ? ( - <button - type="button" - className={css.twist} - aria-label={row.expanded ? 'Collapse' : 'Expand'} - onClick={(e) => { e.stopPropagation(); onToggle(node.id) }} - > - <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> - </button> - ) - : <span className={css.slot} />} - <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span> - <span className={css.title}>{row.title}</span> - <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span> - </div> - ) - return ( - <> - {ownRow} - {node.children.map(child => ( - <SessionNodeItem - key={child.id} - node={child} - depth={depth + 1} - currentId={currentId} - now={now} - onOpen={onOpen} - onToggle={onToggle} - /> - ))} - </> - ) -} diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index 591aef2330..729855cdde 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -48,7 +48,6 @@ refresh straight into the collapsed state renders statically. */ .railIn .iconButton, .railIn .newSession, -.railIn .searchButton, .railIn .foot { animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards; } @@ -184,133 +183,9 @@ max-width: 0; } -/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons; - the right-anchored new-workspace button is the row's rail survivor. */ -.sectionHeader { - flex: none; - display: flex; - align-items: center; - justify-content: flex-end; - gap: 4px; - height: 36px; - padding-left: 12px; - margin-bottom: 4px; - box-sizing: border-box; - border-radius: 12px; - overflow: hidden; - color: var(--dsw-alias-label-tertiary); -} - -.collapsed .sectionHeader { - height: 36px; - padding-left: 0; - margin-bottom: 12px; -} - -.sectionLabel { - flex: 1; - min-width: 0; - overflow: hidden; - white-space: nowrap; - line-height: 20px; -} - -/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the - rail's search control. Upstream binds a dedicated design-system variable (light - #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token - pinned to the static scale mirrors it (ruled compliant: indirect via - custom property, upstream-variable equivalent). */ -.search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); - flex: none; - display: flex; - align-items: center; - gap: 8px; - height: 38px; - margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */ - padding: 0 14px; - box-sizing: border-box; - border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; - background: var(--dsh-search-input-fill); - color: var(--dsw-alias-label-caption); - overflow: hidden; -} - -:global(body[data-ds-dark-theme]) .search { - --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); -} - -.collapsed .search { - height: 36px; - padding: 0; - margin: 0 0 12px; - gap: 0; - border-color: transparent; - background: transparent; -} - -/* The capsule's leading icon, upgraded to the rail's search control. While - expanded it is decorative: pointer-events off so clicks reach the label - (native input focus); collapsed it becomes the hit target. */ -.searchButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - pointer-events: none; - color: inherit; -} - -.collapsed .searchButton { - width: 36px; - height: 36px; - pointer-events: auto; - cursor: pointer; - color: var(--dsw-alias-label-primary); -} - -.collapsed .searchButton:hover { - background: var(--dsw-alias-interactive-bg-hover); -} - -.searchInput { - flex: 1; - min-width: 0; - border: none; - outline: none; - background: transparent; - font-size: 14px; - line-height: 20px; - color: var(--dsw-alias-label-primary); -} - -.searchInput::placeholder { - color: var(--dsw-alias-label-tertiary); -} - -.clearButton { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border: none; - border-radius: 50%; - padding: 0; - background: transparent; - cursor: pointer; - color: var(--dsw-alias-label-secondary); -} - -/* Tree seat: always mounted so the foot never moves; the tree content inside - is wide-only and clips while the column squeezes. */ -.listArea { +/* Region seat: always mounted so the foot never moves; the browser inside + handles its own wide/rail content. */ +.regionArea { flex: 1; min-height: 0; display: flex; @@ -318,60 +193,6 @@ overflow: hidden; } -/* Relative for the bottom fade overlay. */ -.treeBody { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - position: relative; -} - -/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, - transparent -> sidebar fill so it tracks the theme. */ -.fade { - position: absolute; - left: 0; - right: 0; - bottom: 0; - height: 72px; - background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); - pointer-events: none; -} - -/* Tree list: the only scrolling region. Block, not a flex column: as flex - items the 54/34 rows would shrink under content overflow (scrollHeight - collapses onto clientHeight and wheel scrolling dies); block children keep - their design heights and the 4px rhythm rides margins instead of gap. */ -.list { - flex: 1; - min-height: 0; - overflow-y: auto; - padding-bottom: 12px; -} - -/* One workspace section: header row + expanded session run. Rows inside - keep the former flat-list 4px gap as sibling margins; the inter-group - breathing room (figma 133:7661 batch separator, 20px after an expanded - run) rides the NEXT section's top margin so the last group adds none. */ -.groupSection > * + * { - margin-top: 4px; -} - -.groupSection + .groupSection { - margin-top: 4px; -} - -.groupSection:has([aria-expanded='true']) + .groupSection { - margin-top: 20px; -} - -.empty { - padding: 16px 12px; - color: var(--dsw-alias-label-tertiary); - font-size: 13px; -} - /* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical margins fold into the row so the hover pill spans the full 49px. */ .foot { @@ -418,7 +239,6 @@ .fading > *, .railIn .iconButton, .railIn .newSession, - .railIn .searchButton, .railIn .foot { transition: none; animation: none; diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index 931eb1ba17..bcb03780d5 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -1,170 +1,38 @@ /** - * Collapse is a slide plus crossfade: content freezes at its expanded - * width (inline style) and fades out in place while the sliding column - * (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle - * the wide-only content (brand, labels, input, tree) unmounts, dropping - * the sessions subscription, and the control rows snap to the 56px rail - * (one icon each, same top-down order) fading in as the slide ends. Rail - * search expands and focuses the search box. + * Sidebar shell: column geometry only. Collapse is a slide plus crossfade: + * content freezes at its expanded width (inline style) and fades out in place + * while the sliding column (AppFrame grid tracks) clips it — nothing reflows + * mid-slide. At settle the wide-only content unmounts and the control rows + * snap to the 56px rail (one icon each, same top-down order) fading in as the + * slide ends. The workspace/session browsing region between the New Session + * button and the foot is the `sidebar.workspaces` registrant's; the shell + * hands it the wide flag and an expand request callback. */ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { BrandWordmark, FishLogo, - IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16, - IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14, - Menu, Tooltip, + IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14, + Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootComponentProps } from './contract/slots.ts' -import { deriveGroups, UNGROUPED_KEY } from './tree.ts' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx' import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 -/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ -const EXPAND_SLIDE_MS = 300 - -const GROUP_BY_ITEMS = [ - { id: 'workspace', label: 'Workspace' }, - // Only workspace grouping is implemented. - { id: 'update', label: 'Update', disabled: true }, - { id: 'status', label: 'Status', disabled: true }, -] - -/** Immutable membership toggle for the local expansion arrays. */ -function toggled(list: readonly string[], key: string): string[] { - return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] -} - -/** Group-by strategy menu; own open state so it resets with the wide chrome. */ -function GroupByMenu() { - const [open, setOpen] = useState(false) - return ( - <Menu - open={open} - onClose={() => { setOpen(false) }} - items={GROUP_BY_ITEMS} - selectedId="workspace" - onSelect={() => { setOpen(false) }} - align="end" - anchor={( - <button - type="button" - className={clsx(css.iconButton, css.wide)} - aria-label="Group by" - onClick={() => { setOpen((v) => !v) }} - > - <IconPersonalizationOutline16 /> - </button> - )} - /> - ) -} - -type SessionTreeProps = Pick< - SidebarRootComponentProps, - 'useSessions' | 'startSession' | 'open' -> & { - workspaces: readonly WorkspaceView[] - /** Live search filter owned by the root (the query outlives the tree). */ - query: string -} - -/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ -function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) { - const list = useSessions((s) => s) - const current = list.current - const [expandedProjects, setExpandedProjects] = useState<string[]>([]) - const [expandedSessions, setExpandedSessions] = useState<string[]>([]) - // Re-expand when publication moves the selected intent into a real Workspace. - const intent = list.intent - const intentWorkspaceId = intent?.target.kind === 'workspace' - ? intent.target.workspaceId - : undefined - const currentGroup = current === undefined - ? undefined - : intent?.sessionId === current - ? intentWorkspaceId - : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) - ?? UNGROUPED_KEY - useEffect(() => { - if (current === undefined || currentGroup === undefined) return - setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) - }, [current, currentGroup]) - const groups = useMemo( - () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), - [list, workspaces, expandedProjects, expandedSessions, query], - ) - const now = Date.now() - - return ( - <div className={clsx(css.treeBody, css.wide)}> - <div className={css.list} role="tree" aria-label="Sessions"> - {groups.length === 0 && ( - <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> - )} - {groups.map(group => ( - // Group section: header row + expanded session subtree. The - // inter-group breathing room (former flat-list batch separator) - // is the section's own margin (SidebarRoot.module.css). - <div key={group.key} className={css.groupSection}> - <ProjectRowItem - group={group} - onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }} - onCreate={() => { - if (group.workspaceId !== undefined) startSession(group.workspaceId) - }} - /> - {group.intentHere && <IntentRowItem />} - {group.sessions.map(node => ( - <SessionNodeItem - key={node.id} - node={node} - depth={0} - currentId={current} - now={now} - onOpen={open} - onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} - /> - ))} - </div> - ))} - </div> - <span className={css.fade} /> - </div> - ) -} - /** - * Render the sidebar column. + * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). * @returns the sidebar element tree. */ export function SidebarRoot({ collapsed, width, - useSessions, - useWorkspaces, startSession, - open, toggleSidebar, renderSlot, }: SidebarRootComponentProps) { - const workspaces = useWorkspaces(state => state.items) - // The query outlives the tree and the input (both wide-only) so collapsing - // does not silently drop an in-progress filter. - const [query, setQuery] = useState('') - const searchInput = useRef<HTMLInputElement | null>(null) - // Section-header + opens the workspace picker (same popover in wide and - // rail states; the hole sits beside the button and opens rightward). - const [wsPickerOpen, setWsPickerOpen] = useState(false) - // Placement anchor for the picker popover: the slot span renders elsewhere - // in the DOM, so the picker positions off this button's rect. - const wsPlusRef = useRef<HTMLButtonElement>(null) - // Wide content stays mounted while the collapse animates (fading via // .collapsed .wide), unmounts at settle, and remounts right away on expand. const [settled, setSettled] = useState(collapsed) @@ -186,19 +54,6 @@ export function SidebarRoot({ const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true - // Rail search = expand + land in the search box: the flag arms before the - // expand toggle; once expanded the input is mounted and takes focus. - const [searchOnExpand, setSearchOnExpand] = useState(false) - useEffect(() => { - if (!collapsed && searchOnExpand) { - const timer = window.setTimeout(() => { - searchInput.current?.focus({ preventScroll: true }) - setSearchOnExpand(false) - }, EXPAND_SLIDE_MS) - return () => { window.clearTimeout(timer) } - } - }, [collapsed, searchOnExpand]) - return ( <div className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)} @@ -238,82 +93,15 @@ export function SidebarRoot({ </button> </Tooltip> - <div className={css.sectionHeader}> - {wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>} - {wide && <GroupByMenu />} - <Tooltip label="New Workspace" disabled={wide}> - <button - ref={wsPlusRef} - type="button" - className={css.iconButton} - aria-label="Create workspace" - onClick={() => { setWsPickerOpen(v => !v) }} - > - <IconProjectAddOutline16 size={wide ? 16 : 18} /> - </button> - </Tooltip> - {/* Picker hole beside the + (same site in wide and rail states). */} - {renderSlot('sidebar.workspace', { - open: wsPickerOpen, - anchorRef: wsPlusRef, - onPick: (workspaceId) => { - setWsPickerOpen(false) - startSession(workspaceId) - }, - onClose: () => { setWsPickerOpen(false) }, + {/* The browsing region fills the column between the controls and the + foot in both states; its rail icon column rides the same slot. */} + <div className={css.regionArea}> + {renderSlot('sidebar.workspaces', { + wide, + expandSidebar: () => { if (collapsed) toggleSidebar() }, })} </div> - {/* Expanded: the row is a click-to-focus field (the leading icon is - decorative). Collapsed: the icon is the rail's search control. */} - <div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}> - <Tooltip label="Search" disabled={wide}> - <button - type="button" - className={css.searchButton} - aria-label="Search sessions" - tabIndex={collapsed ? 0 : -1} - onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }} - > - <IconSearchOutline16 size={wide ? 14 : 18} /> - </button> - </Tooltip> - {wide && ( - <input - ref={searchInput} - className={clsx(css.searchInput, css.wide)} - type="text" - placeholder="Search name, keywords..." - value={query} - onChange={(e) => { setQuery(e.target.value) }} - /> - )} - {wide && query !== '' && ( - <button - type="button" - className={clsx(css.clearButton, css.wide)} - aria-label="Clear search" - onClick={() => { setQuery('') }} - > - <IconCloseFill14 /> - </button> - )} - </div> - - {/* Always-mounted seat: its flex slot pins the foot to the bottom in - both states while the tree itself is wide-only. */} - <div className={css.listArea}> - {wide && ( - <SessionTree - useSessions={useSessions} - workspaces={workspaces} - startSession={startSession} - open={open} - query={query} - /> - )} - </div> - <div className={css.foot} role="button" tabIndex={0} aria-label="Settings"> <IconSettingsOutline14 size={wide ? 14 : 18} /> {wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>} diff --git a/packages/client/ui-sidebar/src/client/contract/slots.ts b/packages/client/ui-sidebar/src/client/contract/slots.ts index 0334ca88c9..1fd84312fa 100644 --- a/packages/client/ui-sidebar/src/client/contract/slots.ts +++ b/packages/client/ui-sidebar/src/client/contract/slots.ts @@ -1,69 +1,54 @@ /** * Sidebar slot contract: the registrant-side props composition for the - * layout-owned `sidebar` slot and the Workspace picker hole declared here. - * The runtime share combines layout-owned page state and actions with the - * global useSessions and useWorkspaces hooks; the injected share adds the - * runtime navigation actions and sidebar toggle. + * layout-owned `sidebar` slot, plus the workspace-browser hole this shell + * declares. The shell owns column geometry (fold state machine, brand row, + * New Session, Settings); everything between the section header and the list + * bottom is the `sidebar.workspaces` registrant's (ui-workspace). */ -import type { RefObject } from 'react' import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every // program that sees this contract, so PropsRuntime<'sidebar'> resolves. import type {} from '@deepseek-ai/dsh-client-ui-layout/client' -import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' declare module '@deepseek-ai/dsh-client-ui-slots' { interface SlotMap { /** - * The workspace picker hole in the sidebar section header (anchored at - * the + button). Declared by this package's 'sidebar' entry (declaring - * is claiming); ui-workspace registers the picker. + * The workspace/session browsing region: section header, search, the + * grouped/flat session list, and every workspace dialog. Declared by this + * package's 'sidebar' entry (declaring is claiming); ui-workspace + * registers the browser. */ - 'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps } + 'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps } } } /** - * Owner share of the sidebar workspace hole: popover geometry plus the - * sidebar's pick semantics. The picked Host Workspace is already real; the - * callback starts a frontend Session Intent targeted to it. + * Owner share of the browser hole — the only facts crossing the shell/region + * seam. Business data and actions arrive through the region's own inject. */ -export interface SidebarWorkspaceOwnerProps { - /** Popover visibility (+ button toggle state, host-local). */ - open: boolean - /** - * The + button element — the popover's placement anchor. The picker's - * slot span renders elsewhere in the DOM, so without this the menu - * positions off the zero-size placement span (order-dependent). Optional - * only until the host passes it; absent falls back to in-place placement. - */ - anchorRef?: RefObject<HTMLElement> - /** Start a frontend Session in a selected or newly created real Workspace. */ - onPick: (workspaceId: WorkspaceId) => void - /** Close the popover (outside click / Escape / post-pick). */ - onClose: () => void +export interface SidebarSectionOwnerProps { + /** Shell fold-state output: wide renders the full browser, rail the icon column. */ + wide: boolean + /** Rail icons request expansion; the browser rides the wide flip for focus. */ + expandSidebar: () => void } /** * Registrant-private injected share (arrives via the register inject - * factory). Host Workspace and Session data use the global framework hooks; - * navigation and panel actions are plain callbacks, and viewing state remains - * component-local. A type alias supplies the implicit index signature required - * by the registry. + * factory). The shell keeps only its own controls: starting a Session from + * the New Session button and toggling the column. */ export type SidebarRootInjected = { /** Start or replace the current frontend Session Intent. */ startSession: (workspaceId?: WorkspaceId, prompt?: string) => void - /** Open a real Session. */ - open: (sessionId: SessionId) => void /** Toggle the sidebar column through the layout service. */ toggleSidebar: () => void } /** - * Full component props: layout owner state/actions plus global useSessions - * and useWorkspaces, the declared Workspace picker render share, and this - * package's injected callback. No store is registered. + * Full component props: layout owner state/actions plus the browser hole's + * render share and this package's injected callbacks. No store is registered. */ export type SidebarRootComponentProps = - PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected + PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected diff --git a/packages/client/ui-sidebar/src/client/index.ts b/packages/client/ui-sidebar/src/client/index.ts index 0a1c8ebb12..0493ef2279 100644 --- a/packages/client/ui-sidebar/src/client/index.ts +++ b/packages/client/ui-sidebar/src/client/index.ts @@ -1,28 +1,27 @@ -/** Registers the sidebar UI into the layout-owned slot. */ +/** Registers the sidebar shell into the layout-owned slot. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' import type { SidebarRootInjected } from './contract/slots.ts' import { SidebarRoot } from './SidebarRoot.tsx' -export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts' +export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts' /** Services required by the sidebar plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces'] +export const inject = ['slots', 'layout', 'workspaces'] -/** Registers the sidebar component and its service callbacks. +/** Registers the sidebar shell and its service callbacks. * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { const injectProps = (): SidebarRootInjected => ({ startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, - open: (sessionId) => { ctx.sessions.open(sessionId) }, toggleSidebar: () => { ctx.layout.toggleSidebar() }, }) ctx.effect( () => ctx.slots.register({ name: 'sidebar', - // SidebarRoot owns this picker site; ui-workspace registers the shared - // picker that selects a Host Workspace for a frontend Session Intent. - children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } }, + // The shell owns geometry; ui-workspace registers the whole browsing + // region (header, search, session list, workspace dialogs) here. + children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } }, inject: injectProps, }, SidebarRoot), 'ui-sidebar: slot registration', diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index 5d285bd20c..681454691c 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,4 +1,4 @@ -/** Sidebar slot registration and its plain runtime/layout callbacks. */ +/** Sidebar shell slot registration and its plain runtime/layout callbacks. */ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -9,10 +9,8 @@ async function bench(declare = true) { const ctx = new Context() await ctx.plugin(SlotsService).await() const layout = { toggleSidebar: vi.fn() } - const sessions = { open: vi.fn() } const workspaces = { startSession: vi.fn() } ctx.provide('layout', layout) - ctx.provide('sessions', sessions as never) ctx.provide('workspaces', workspaces as never) const slots = ctx.get('slots') as SlotsService if (declare) { @@ -21,25 +19,23 @@ async function bench(declare = true) { () => null, ) } - return { ctx, slots, layout, sessions, workspaces } + return { ctx, slots, layout, workspaces } } describe('ui-sidebar apply', () => { it('declares only the services it uses', () => { - expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces']) + expect(inject).toEqual(['slots', 'layout', 'workspaces']) }) - it('registers the sidebar and declares its Workspace picker hole', async () => { + it('registers the shell and declares the browsing-region hole', async () => { const b = await bench() await b.ctx.plugin({ inject: [...inject], apply }).await() expect(b.slots.entries('sidebar')).toHaveLength(1) - expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' }) + expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' }) const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)() - expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar']) + expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar']) injected.startSession('workspace' as never, 'prompt') expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt') - injected.open('session' as never) - expect(b.sessions.open).toHaveBeenCalledWith('session') injected.toggleSidebar() expect(b.layout.toggleSidebar).toHaveBeenCalledOnce() }) @@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => { await fiber.await() await fiber.dispose() expect(b.slots.entries('sidebar')).toHaveLength(0) - expect(b.slots.spec('sidebar.workspace')).toBeUndefined() + expect(b.slots.spec('sidebar.workspaces')).toBeUndefined() }) }) diff --git a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx index 26adafd793..6bdba770eb 100644 --- a/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx +++ b/packages/client/ui-sidebar/tests/sidebar-root.spec.tsx @@ -1,79 +1,42 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' -import type { - SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, -} from '@deepseek-ai/dsh-client-runtime/client' -import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts' import { SidebarRoot } from '../src/client/SidebarRoot.tsx' afterEach(() => { cleanup() vi.useRealTimers() }) -const sid = (id: string) => id as SessionId -const wid = (id: string) => id as WorkspaceId -const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) -const workspace: WorkspaceView = { - workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')], - createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', -} -const sessions: SessionListState = { - ids: [sid('s1')], - byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } }, - current: undefined, phase: 'ready', - intent: undefined, -} -const workspaces: WorkspaceListState = { - items: [workspace], state: 'idle', phase: 'ready', error: null, - intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId, -} -function mount(sessionState: SessionListState = sessions) { - const startSession = vi.fn() - const open = vi.fn() - let pickerOwner: unknown - const view = render( - <SidebarRoot - collapsed={false} width={300} - useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)} - startSession={startSession} open={open} toggleSidebar={vi.fn()} - renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} - />, - ) - return { view, startSession, open, pickerOwner: () => pickerOwner } -} +// The shell never reads the global hooks itself, but they ride the standard +// props share; stub them as never-called functions. +const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never -function mountSidebar({ - sessionState = sessions, - workspaceState = workspaces, - collapsed = false, - width = 300, -}: { - sessionState?: SessionListState - workspaceState?: WorkspaceListState - collapsed?: boolean - width?: number -} = {}) { +function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) { const startSession = vi.fn() - const open = vi.fn() const toggleSidebar = vi.fn() - let pickerOwner: unknown - let current = { sessionState, workspaceState, collapsed, width } + let regionOwner: SidebarSectionOwnerProps | undefined + let current = { collapsed, width } const root = () => ( <SidebarRoot collapsed={current.collapsed} width={current.width} - useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)} - startSession={startSession} open={open} toggleSidebar={toggleSidebar} - renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']} + useSessions={neverHook} useWorkspaces={neverHook} + startSession={startSession} toggleSidebar={toggleSidebar} + renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => { + regionOwner = owner + return <div data-testid="region" data-wide={owner.wide} /> + }) as SidebarRootComponentProps['renderSlot']} /> ) const view = render(root()) return { startSession, - open, toggleSidebar, - pickerOwner: () => pickerOwner, + regionOwner: () => { + if (regionOwner === undefined) throw new Error('region owner not rendered') + return regionOwner + }, rerender(next: Partial<typeof current>) { current = { ...current, ...next } view.rerender(root()) @@ -81,181 +44,40 @@ function mountSidebar({ } } -describe('SidebarRoot', () => { - it('renders real Workspaces from useWorkspaces and routes New Session', () => { - const b = mount() - expect(screen.getByText('Project')).toBeTruthy() +describe('SidebarRoot shell', () => { + it('routes New Session and the column toggle', () => { + const b = mountShell() fireEvent.click(screen.getByRole('button', { name: 'New session' })) expect(b.startSession).toHaveBeenCalledWith() - }) - - it('shows a frontend Session under its real Workspace and routes its row plus', () => { - const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const } - const b = mount({ - ...sessions, - current: intent.sessionId, - intent, - }) - expect(screen.getByText('New session')).toBeTruthy() - expect(screen.getByText('2 sessions')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'New session in Project' })) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('forwards Workspace picker selection and closes the picker', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void } - expect(owner.open).toBe(true) - owner.onPick(workspace.workspaceId) - expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId) - }) - - it('opens a real Session through the owner action', () => { - const b = mount({ ...sessions, current: sid('intent'), intent: { - sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready', - } }) - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByText('First session')) - expect(b.open).toHaveBeenCalledWith(sid('s1')) - }) - - it('opens, selects, dismisses, and toggles the group-by menu', () => { - mount() - const button = screen.getByRole('button', { name: 'Group by' }) - - fireEvent.click(button) - fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' })) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(screen.queryByRole('menu')).toBeNull() - - fireEvent.click(button) - fireEvent.click(button) - expect(screen.queryByRole('menu')).toBeNull() - }) - - it('routes every Workspace picker close path', () => { - const b = mount() - fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) - const owner = b.pickerOwner() as { open: boolean; onClose(): void } - expect(owner.open).toBe(true) - act(() => { owner.onClose() }) - expect((b.pickerOwner() as { open: boolean }).open).toBe(false) - }) - - it('focuses, filters, and clears search while distinguishing both empty states', () => { - mount() - const input = screen.getByPlaceholderText('Search name, keywords...') - fireEvent.click(input.parentElement!) - expect(document.activeElement).toBe(input) - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - - fireEvent.change(input, { target: { value: 'missing' } }) - expect(screen.getByText('No matches')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) - expect(screen.queryByText('No matches')).toBeNull() - - cleanup() - const emptySessions = listState() - const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined } - mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces }) - expect(screen.getByText('No sessions yet')).toBeTruthy() - }) - - it('toggles Workspace and nested Session expansion in both directions', () => { - const parent = sid('parent') - const child = sid('child') - const nestedSessions: SessionListState = { - ...sessions, - ids: [parent, child], - byId: { - [parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 }, - [child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent }, - }, - } - const nestedWorkspace: WorkspaceListState = { - ...workspaces, - items: [{ ...workspace, sessionIds: [parent, child] }], - } - mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace }) - - fireEvent.click(screen.getByText('Project')) - fireEvent.click(screen.getByRole('button', { name: 'Expand' })) - expect(screen.getByText('Child')).toBeTruthy() - fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) - expect(screen.queryByText('Child')).toBeNull() - fireEvent.click(screen.getByText('Project')) - expect(screen.queryByText('Parent')).toBeNull() - }) - - it('does not start a Session from an Ungrouped row create action', () => { - const loose = sid('loose') - const looseSessions: SessionListState = { - ...listState(), - ids: [loose], - byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } }, - current: loose, - } - const b = mountSidebar({ - sessionState: looseSessions, - workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined }, - }) - fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) - expect(b.startSession).not.toHaveBeenCalled() - }) - - it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => { - const b = mountSidebar() - fireEvent.click(screen.getByText('Project')) - const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] } - b.rerender({ - sessionState: { ...sessions, current: sid('s1') }, - workspaceState: { ...workspaces, items: [other, workspace] }, - }) - expect(screen.getByText('First session')).toBeTruthy() - - b.rerender({ - sessionState: { - ...sessions, - current: sid('draft'), - intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' }, - }, - }) - expect(screen.getByText('Project')).toBeTruthy() - }) - - it('renders the static collapsed rail and expands rail search into focused input', () => { - vi.useFakeTimers() - const b = mountSidebar({ collapsed: true }) - expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' })) - expect(b.toggleSidebar).toHaveBeenCalledOnce() - - fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) - expect(b.toggleSidebar).toHaveBeenCalledTimes(2) - b.rerender({ collapsed: false }) - const input = screen.getByPlaceholderText('Search name, keywords...') - act(() => { vi.advanceTimersByTime(300) }) - expect(document.activeElement).toBe(input) - }) - - it('keeps wide content during live collapse, then settles to the rail', () => { - vi.useFakeTimers() - const b = mountSidebar({ width: 320 }) fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' })) expect(b.toggleSidebar).toHaveBeenCalledOnce() - b.rerender({ collapsed: true, width: 56 }) - expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy() - act(() => { vi.advanceTimersByTime(150) }) - expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + }) + + it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => { + const b = mountShell() + expect(b.regionOwner().wide).toBe(true) + // Expanded: the request is a no-op (no accidental collapse). + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).not.toHaveBeenCalled() + }) + + it('keeps the region mounted through collapse and expands on its request', () => { + vi.useFakeTimers() + const b = mountShell() + b.rerender({ collapsed: true }) + // Wide content survives the crossfade window, then settles into the rail. + expect(b.regionOwner().wide).toBe(true) + vi.advanceTimersByTime(200) + b.rerender({}) + expect(b.regionOwner().wide).toBe(false) + expect(screen.getByTestId('region')).toBeTruthy() + b.regionOwner().expandSidebar() + expect(b.toggleSidebar).toHaveBeenCalledOnce() + }) + + it('renders statically collapsed on a cold start (no crossfade classes)', () => { + const b = mountShell({ collapsed: true }) + expect(b.regionOwner().wide).toBe(false) expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy() }) }) - -function listState(): SessionListState { - return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined } -} diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 0a6c82c0cf..c36486d2fd 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -35,6 +35,9 @@ "watch": "tsdown --watch" }, "license": "BSD-3-Clause", + "dependencies": { + "clsx": "^2.0.0" + }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css new file mode 100644 index 0000000000..d6375cb698 --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -0,0 +1,265 @@ +/* Workspace browsing region (fills the sidebar shell's hole): section + header, search capsule, and the scrolling session list. Wide/rail + variants ride the shell's fold state through the `wide` owner prop — + rail state renders only the two 36x36 icon controls. */ + +.root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.iconButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +.iconButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Section header: 36px, "Workspaces/Sessions" label + group-by / + new-workspace buttons; the right-anchored new-workspace button is the + row's rail survivor. */ +.sectionHeader { + flex: none; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + height: 36px; + padding-left: 12px; + margin-bottom: 4px; + box-sizing: border-box; + border-radius: 12px; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); +} + +.sectionLabel { + flex: 1; + min-width: 0; + overflow: hidden; + white-space: nowrap; + line-height: 20px; +} + +/* Search input: 38px capsule (figma 133:7649); rail state renders it as the + region's search control. Upstream binds a dedicated design-system variable + (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component + token pinned to the static scale mirrors it. */ +.search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-75); + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: 38px; + margin: 0 2px 12px; + padding: 0 14px; + box-sizing: border-box; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 24px; + background: var(--dsh-search-input-fill); + color: var(--dsw-alias-label-caption); + overflow: hidden; +} + +:global(body[data-ds-dark-theme]) .search { + --dsh-search-input-fill: var(--dsw-static-neutral-bluish-900); +} + +/* The capsule's leading icon: decorative while wide (pointer-events off so + clicks reach the input), the hit target in rail state. */ +.searchButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + pointer-events: none; + color: inherit; +} + +.searchInput { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: transparent; + font-size: 14px; + line-height: 20px; + color: var(--dsw-alias-label-primary); +} + +.searchInput::placeholder { + color: var(--dsw-alias-label-tertiary); +} + +.clearButton { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 50%; + padding: 0; + background: transparent; + cursor: pointer; + color: var(--dsw-alias-label-secondary); +} + +/* Rail variant (own .rail class from the wide owner prop — the region never + reads the shell's class names): the two icon controls stack as 36x36 + circles matching the shell's rail rhythm. */ +.rail .sectionHeader { + padding-left: 0; + margin-bottom: 12px; +} + +.rail .iconButton { + width: 36px; + height: 36px; + color: var(--dsw-alias-label-primary); +} + +.rail .search { + height: 36px; + padding: 0; + margin: 0 0 12px; + gap: 0; + border-color: transparent; + background: transparent; +} + +.rail .searchButton { + width: 36px; + height: 36px; + pointer-events: auto; + cursor: pointer; + color: var(--dsw-alias-label-primary); +} + +.rail .searchButton:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* List seat: always mounted so the shell foot never moves. */ +.listArea { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Relative for the bottom fade overlay. */ +.treeBody { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + position: relative; +} + +/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom, + transparent -> sidebar fill so it tracks the theme. */ +.fade { + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 72px; + background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill)); + pointer-events: none; +} + +/* Wide-only content fades back in on expand remount (mirrors the shell). */ +.wide { + animation: wide-in 200ms var(--ds-ease-in-out); +} + +@keyframes wide-in { + from { opacity: 0; } +} + +/* List: the only scrolling region. Block, not a flex column: as flex items + the 54/34 rows would shrink under content overflow; block children keep + their design heights and the 4px rhythm rides margins instead of gap. */ +.list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding-bottom: 12px; +} + +/* One workspace section: header row + expanded session run. Rows inside + keep the former flat-list 4px gap as sibling margins; the inter-group + breathing room (figma 133:7661 batch separator, 20px after an expanded + run) rides the NEXT section's top margin so the last group adds none. */ +.groupSection > * + * { + margin-top: 4px; +} + +.groupSection + .groupSection { + margin-top: 4px; +} + +.groupSection:has([aria-expanded='true']) + .groupSection { + margin-top: 20px; +} + +.empty { + padding: 16px 12px; + color: var(--dsw-alias-label-tertiary); + font-size: 13px; +} + +/* Rename dialog form (same figma dialog family as the create modals). */ +.renameInput { + box-sizing: border-box; + width: 100%; + height: 44px; + padding: 7px 14px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 22px; + outline: none; + background: transparent; + font-size: 14px; + font-weight: 400; + line-height: 22px; + color: var(--dsw-alias-label-primary); +} + +.renameInput:disabled { + color: var(--dsw-alias-label-dimmed); +} + +.renameError { + margin-top: 8px; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-error-primary); +} + +@media (prefers-reduced-motion: reduce) { + .wide { + animation: none; + } +} diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx new file mode 100644 index 0000000000..e7d0cdc38c --- /dev/null +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -0,0 +1,428 @@ +/** + * The workspace/session browsing region filling the sidebar shell's + * `sidebar.workspaces` hole: section header (title + group-by + new + * workspace), search, the grouped tree or flat list, and the workspace + * dialogs. Wide state renders the full browser; rail state renders the two + * region icons (search / new workspace), each requesting shell expansion + * through the owner share. The picker menu and create dialogs live in + * WorkspacePicker (same package — direct composition, no slot between them). + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import clsx from 'clsx' +import { + Button, IconCloseFill14, IconPersonalizationOutline16, + IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from './contract/slots.ts' +import type { SessionNode } from './tree.ts' +import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx' +import { WorkspaceCreateFlow } from './WorkspacePicker.tsx' +import css from './WorkspaceBrowser.module.css' + +/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */ +const EXPAND_SLIDE_MS = 300 + +const GROUP_BY_ITEMS = [ + { type: 'label' as const, id: 'group-by', text: 'Group by' }, + { id: 'workspace', label: 'WorkSpace' }, + { id: 'flat', label: 'In one list' }, +] + +/** Immutable membership toggle for the local expansion arrays. */ +function toggled(list: readonly string[], key: string): string[] { + return list.includes(key) ? list.filter((k) => k !== key) : [...list, key] +} + +/** Group-by strategy menu; own open state so it resets with the wide chrome. */ +function GroupByMenu({ groupBy, onPick }: { + groupBy: 'workspace' | 'flat' + onPick: (mode: 'workspace' | 'flat') => void +}) { + const [open, setOpen] = useState(false) + return ( + <Menu + open={open} + onClose={() => { setOpen(false) }} + items={GROUP_BY_ITEMS} + selectedId={groupBy} + onSelect={(id) => { + if (id === 'workspace' || id === 'flat') onPick(id) + setOpen(false) + }} + align="end" + // Portal: the section header clips overflow, so an in-place list would + // be cut off at the header's bounds. + portal + anchor={( + <button + type="button" + className={clsx(css.iconButton, css.wide)} + aria-label="Group by" + onClick={() => { setOpen((v) => !v) }} + > + <IconPersonalizationOutline16 /> + </button> + )} + /> + ) +} + +/** In-flight root-row drag: source identity plus the current insert marker. */ +interface DragState { + workspaceId: WorkspaceId + sessionId: SessionNode['id'] + /** Row the marker sits on and which half (insert above/below it). */ + over: { id: SessionNode['id']; half: 'before' | 'after' } | null +} + +type SessionTreeProps = Pick< + WorkspaceBrowserProps, + 'useSessions' | 'startSession' | 'open' | 'insertSessionBefore' +> & { + workspaces: readonly WorkspaceView[] + /** Live search filter owned by the browser root (the query outlives the tree). */ + query: string + /** Open the browser-owned rename dialog for a real Workspace group. */ + onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void +} + +/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */ +function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) { + const list = useSessions((s) => s) + const current = list.current + const [expandedProjects, setExpandedProjects] = useState<string[]>([]) + const [expandedSessions, setExpandedSessions] = useState<string[]>([]) + // Transient drag viewing state (never store-bound; order truth stays Host-side). + const [drag, setDrag] = useState<DragState | null>(null) + // Re-expand when publication moves the selected intent into a real Workspace. + const intent = list.intent + const intentWorkspaceId = intent?.target.kind === 'workspace' + ? intent.target.workspaceId + : undefined + const currentGroup = current === undefined + ? undefined + : intent?.sessionId === current + ? intentWorkspaceId + : (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined) + ?? UNGROUPED_KEY + useEffect(() => { + if (current === undefined || currentGroup === undefined) return + setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup])) + }, [current, currentGroup]) + const groups = useMemo( + () => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }), + [list, workspaces, expandedProjects, expandedSessions, query], + ) + const now = Date.now() + + return ( + <div className={clsx(css.treeBody, css.wide)}> + <div className={css.list} role="tree" aria-label="Sessions"> + {groups.length === 0 && ( + <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> + )} + {groups.map(group => ( + // Group section: header row + expanded session subtree. The + // inter-group breathing room (former flat-list batch separator) + // is the section's own margin (WorkspaceBrowser.module.css). + <div key={group.key} className={css.groupSection}> + <ProjectRowItem + group={group} + onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }} + onCreate={() => { + if (group.workspaceId !== undefined) startSession(group.workspaceId) + }} + onRename={group.workspaceId === undefined + ? undefined + : () => { + if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) + }} + /> + {group.expanded && group.intentHere && <IntentRowItem />} + {group.sessions.map((node, index) => { + // Draggable: real-workspace group roots outside search. The drag + // never leaves its group — rows of other groups show no markers + // and reject drops (visual movement confined to this section). + const draggable = group.workspaceId !== undefined && query === '' + const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId + const dragProps = !draggable || group.workspaceId === undefined ? undefined : { + start: () => { + setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null }) + }, + active: sameGroupDrag, + marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, + hover: (half: 'before' | 'after') => { + setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) + }, + drop: (half: 'before' | 'after') => { + if (drag === null) return + const roots = group.sessions + // Anchor = the row the insert line points at ('after' means + // the next root; end-of-list omits the anchor → append). + const anchor = half === 'before' ? node.id : roots[index + 1]?.id + setDrag(null) + if (anchor === drag.sessionId) return + // No-op when the drop lands back on the source position. + const sourceIndex = roots.findIndex(r => r.id === drag.sessionId) + const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor) + if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return + insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => { + console.warn('session reorder rejected:', reason) + }) + }, + end: () => { setDrag(null) }, + } + return ( + <SessionNodeItem + key={node.id} + node={node} + depth={0} + currentId={current} + now={now} + onOpen={open} + onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }} + drag={dragProps} + /> + ) + })} + </div> + ))} + </div> + <span className={css.fade} /> + </div> + ) +} + +/** The flat "In one list" body: every session a top-level row, newest-first. */ +function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) { + const list = useSessions((s) => s) + const rows = useMemo(() => deriveFlat(list, { query }), [list, query]) + const now = Date.now() + // The intent placeholder renders outside search only; it suppresses the + // empty state only while actually rendered (a query hides both). + const intentRow = query === '' && list.intent !== undefined + return ( + <div className={clsx(css.treeBody, css.wide)}> + <div className={css.list} role="tree" aria-label="Sessions"> + {rows.length === 0 && !intentRow && ( + <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> + )} + {intentRow && <IntentRowItem flat />} + {rows.map(node => ( + <SessionNodeItem + key={node.id} + node={node} + depth={0} + currentId={list.current} + now={now} + onOpen={open} + onToggle={() => {}} + flat + /> + ))} + </div> + <span className={css.fade} /> + </div> + ) +} + +/** + * Render the browsing region. + * @param props - composed slot props (shell owner share + store + injected actions). + * @returns the region element tree. + */ +export function WorkspaceBrowser({ + wide, + expandSidebar, + useSessions, + useWorkspaces, + useStore, + actions, + startSession, + open, + renameWorkspace, + insertSessionBefore, + createWorkspace, +}: WorkspaceBrowserProps) { + const workspaces = useWorkspaces(state => state.items) + const groupBy = useStore(s => s.groupBy) + // The query outlives the tree and the input (both wide-only) so collapsing + // does not silently drop an in-progress filter. + const [query, setQuery] = useState('') + const searchInput = useRef<HTMLInputElement | null>(null) + // Section-header + opens the picker menu (same popover in wide and rail + // states; the menu anchors on this button). + const [wsPickerOpen, setWsPickerOpen] = useState(false) + const wsPlusRef = useRef<HTMLButtonElement>(null) + + // Rail search = expand + land in the search box: the flag arms before the + // expand request; once the shell flips wide the input mounts and takes focus. + const [searchOnExpand, setSearchOnExpand] = useState(false) + useEffect(() => { + if (wide && searchOnExpand) { + const timer = window.setTimeout(() => { + searchInput.current?.focus({ preventScroll: true }) + setSearchOnExpand(false) + }, EXPAND_SLIDE_MS) + return () => { window.clearTimeout(timer) } + } + }, [wide, searchOnExpand]) + + // Rename dialog (browser-owned so it outlives row unmounts during collapse). + const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null) + const [renameDraft, setRenameDraft] = useState('') + const [renaming, setRenaming] = useState(false) + const [renameError, setRenameError] = useState<string | null>(null) + const renameTrimmed = renameDraft.trim() + const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle + && workspaces.some(w => w.title === renameTrimmed) + const renameBlocked = renaming || renameTrimmed === '' + || renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate + const closeRename = () => { + if (renaming) return + setRenameTarget(null) + setRenameError(null) + } + const confirmRename = () => { + if (renameBlocked || renameTarget === null) return + setRenaming(true) + setRenameError(null) + renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => { + setRenaming(false) + setRenameTarget(null) + }).catch((reason: unknown) => { + setRenaming(false) + setRenameError(reason instanceof Error ? reason.message : String(reason)) + }) + } + + return ( + <div className={clsx(css.root, !wide && css.rail)}> + <div className={css.sectionHeader}> + {wide && ( + <span className={clsx(css.sectionLabel, css.wide)}> + {groupBy === 'flat' ? 'Sessions' : 'Workspaces'} + </span> + )} + {wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />} + <Tooltip label="New Workspace" disabled={wide}> + <button + ref={wsPlusRef} + type="button" + className={css.iconButton} + aria-label="Create workspace" + onClick={() => { + if (!wide) expandSidebar() + setWsPickerOpen(v => !v) + }} + > + <IconProjectAddOutline16 size={wide ? 16 : 18} /> + </button> + </Tooltip> + {/* Picker menu + create dialogs (same package — direct composition). */} + <WorkspaceCreateFlow + open={wsPickerOpen} + anchorRef={wsPlusRef} + useWorkspaces={useWorkspaces} + createWorkspace={createWorkspace} + onPick={(workspaceId) => { + setWsPickerOpen(false) + startSession(workspaceId) + }} + onClose={() => { setWsPickerOpen(false) }} + /> + </div> + + {/* Expanded: the row is a click-to-focus field (the leading icon is + decorative). Rail: the icon is the region's search control. */} + <div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}> + <Tooltip label="Search" disabled={wide}> + <button + type="button" + className={css.searchButton} + aria-label="Search sessions" + tabIndex={wide ? -1 : 0} + onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }} + > + <IconSearchOutline16 size={wide ? 14 : 18} /> + </button> + </Tooltip> + {wide && ( + <input + ref={searchInput} + className={clsx(css.searchInput, css.wide)} + type="text" + placeholder="Search name, keywords..." + value={query} + onChange={(e) => { setQuery(e.target.value) }} + /> + )} + {wide && query !== '' && ( + <button + type="button" + className={clsx(css.clearButton, css.wide)} + aria-label="Clear search" + onClick={() => { setQuery('') }} + > + <IconCloseFill14 /> + </button> + )} + </div> + + {/* Always-mounted seat keeps the region's flex slot while the list + itself is wide-only. */} + <div className={css.listArea}> + {wide && (groupBy === 'flat' + ? <FlatList useSessions={useSessions} open={open} query={query} /> + : ( + <SessionTree + useSessions={useSessions} + workspaces={workspaces} + startSession={startSession} + open={open} + query={query} + insertSessionBefore={insertSessionBefore} + onRenameRequest={(workspaceId, currentTitle) => { + setRenameTarget({ workspaceId, currentTitle }) + setRenameDraft(currentTitle) + setRenameError(null) + }} + /> + ))} + </div> + + <Modal + open={renameTarget !== null} + onClose={closeRename} + title="Rename workspace" + footer={( + <> + <Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button> + <Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button> + </> + )} + > + <input + className={css.renameInput} + value={renameDraft} + aria-label="Workspace name" + autoFocus + disabled={renaming} + onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + confirmRename() + } + }} + /> + {renameDuplicate && ( + <div className={css.renameError} role="alert">A workspace named “{renameTrimmed}” already exists.</div> + )} + {renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>} + </Modal> + </div> + ) +} diff --git a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx index 8f6f483580..2be39875bc 100644 --- a/packages/client/ui-workspace/src/client/WorkspacePicker.tsx +++ b/packages/client/ui-workspace/src/client/WorkspacePicker.tsx @@ -1,9 +1,15 @@ -/** Shared Workspace picker for the sidebar and New Session hero. */ +/** + * Workspace pick/create flow. WorkspaceCreateFlow is the reusable core + * (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same + * package) and wrapped by WorkspacePicker for the conversation empty-state + * slot registration. + */ +import type { RefObject } from 'react' import { useCallback, useState } from 'react' import { Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' import type { WorkspacePickerProps } from './contract/slots.ts' import css from './WorkspacePicker.module.css' @@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new' type ModalKind = 'path' | 'create' | null -export function WorkspacePicker({ +/** Core flow props: the owner supplies popover control and pick semantics. */ +export interface WorkspaceCreateFlowProps { + /** Popover visibility (anchor button toggle state, owner-local). */ + open: boolean + /** The anchor button element — the popover's placement anchor. */ + anchorRef?: RefObject<HTMLElement | null> | undefined + /** Selector hook over the workspace list (framework standard hook). */ + useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S + /** Create or adopt a real Host Workspace. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView> + /** A real Workspace was picked or created. */ + onPick: (workspaceId: WorkspaceId) => void + /** Close the popover (outside click / Escape / post-pick). */ + onClose: () => void +} + +/** + * Render the pick menu plus the two create dialogs. + * @param props - owner-controlled flow props. + * @returns menu + dialog elements. + */ +export function WorkspaceCreateFlow({ open, anchorRef, useWorkspaces, + createWorkspace, onPick, onClose, - createWorkspace, -}: WorkspacePickerProps) { +}: WorkspaceCreateFlowProps) { const workspaceSnapshot = useWorkspaces(state => state) const workspaces = workspaceSnapshot.items const getAnchorRect = useCallback( @@ -194,3 +221,29 @@ export function WorkspacePicker({ </> ) } + +/** + * The conversation empty-state registration: adapts the owner share to the + * core flow (all state and semantics live in the flow / the owner). + * @param props - empty-state slot props (owner share + injected creation callback). + * @returns the flow element. + */ +export function WorkspacePicker({ + open, + anchorRef, + useWorkspaces, + onPick, + onClose, + createWorkspace, +}: WorkspacePickerProps) { + return ( + <WorkspaceCreateFlow + open={open} + anchorRef={anchorRef} + useWorkspaces={useWorkspaces} + createWorkspace={createWorkspace} + onPick={onPick} + onClose={onClose} + /> + ) +} diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index a3045e5070..121e5d60f1 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -1,30 +1,59 @@ /** - * Shared Workspace picker contract for the sidebar and page-local Session Intent hero - * slots. Each runtime share provides its owner's popover controls plus the - * global useWorkspaces hook; this package adds the injected Host Workspace - * creation callback. + * ui-workspace contracts. Two registrations share this package: + * + * - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole — + * the whole browsing region (section header, search, grouped/flat session + * list, workspace dialogs). It registers this package's viewing store and + * consumes the shell's two-fact owner share (wide / expandSidebar). + * - WorkspacePicker fills the conversation empty-state hole (menu + + * create dialogs shared with the browser). */ -import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -// Type-only: pull both owner SlotMap merges into programs that resolve the -// picker runtime union below. +import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +// Type-only: pull the owner SlotMap merges into programs that resolve the +// runtime shares below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' +import type { createWorkspaceViewStore } from '../stores.ts' /** - * Registrant-private injected share. Pick semantics remain in each owner's - * onPick callback; this callback creates only the real Host Workspace. A type - * alias supplies the implicit index signature required by the registry. + * Browser-private injected share (arrives via the register inject factory). + * Data reads use the global framework hooks; these are the Host actions the + * browsing region drives. + */ +export type WorkspaceBrowserInjected = { + /** Start or replace the current frontend Session Intent. */ + startSession: (workspaceId?: WorkspaceId, prompt?: string) => void + /** Open a real Session. */ + open: (sessionId: SessionId) => void + /** Rename a Host Workspace (rejects on name conflict; resolves on durability). */ + renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void> + /** + * Reorder a session inside its Workspace account (DOM-insertBefore + * semantics: omitted anchor appends to the end). The view refreshes from + * the Host response/changed frame; failures leave the order unchanged. + */ + insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void> + /** Explicitly create or adopt a real Workspace before targeting a Session. */ + createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView> +} + +/** Full browser props: shell owner share + viewing store + injected actions. */ +export type WorkspaceBrowserProps = + PropsRuntime<'sidebar.workspaces'> + & PropsStore<ReturnType<typeof createWorkspaceViewStore>> + & WorkspaceBrowserInjected + +/** + * Picker-private injected share. Pick semantics remain in the owner's onPick + * callback; this callback creates only the real Host Workspace. A type alias + * supplies the implicit index signature required by the registry. */ export type WorkspacePickerInjected = { /** Explicitly create or adopt a real Workspace before targeting a Session. */ createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView> } -/** - * Full picker props: either owner's runtime share, including useWorkspaces, - * plus this package's injected creation callback. - */ +/** Full picker props: the empty-state owner share plus the creation callback. */ export type WorkspacePickerProps = - (PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>) - & WorkspacePickerInjected + PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index aa54587650..50ccb3564c 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -1,54 +1,85 @@ /** - * Shared Workspace picker plugin, browser half. WorkspacePicker registers in - * the sidebar and page-local Session Intent hero slots, reads real Host Workspaces - * through the global useWorkspaces hook, and delegates selection semantics to - * each owner. Its injected share creates a Workspace without creating a - * Session. Export discipline: packages/client/AGENTS.md. + * Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills + * the sidebar shell's `sidebar.workspaces` hole (the whole browsing region), + * and WorkspacePicker fills the conversation empty-state hole. Both read real + * Host Workspaces through the global useWorkspaces hook. Export discipline: + * packages/client/AGENTS.md. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { WorkspacePickerInjected } from './contract/slots.ts' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts' +import { createWorkspaceViewStore } from './stores.ts' +import { WorkspaceBrowser } from './WorkspaceBrowser.tsx' import { WorkspacePicker } from './WorkspacePicker.tsx' -export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts' +export type { + WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps, +} from './contract/slots.ts' /** - * Required services (cordis fiber inject). The target slot is declared by - * the ui-sidebar apply, whose activation order relative to this one is NOT - * constrained: dshClient.inject edges are informational (loading/prefetch - * metadata, never apply sequencing) and the sidebar provides no waitable - * service. apply therefore registers via declaration-aware deferral instead - * of assuming order. + * Required services (cordis fiber inject). The target slots are declared by + * the ui-sidebar / ui-conversation applies, whose activation order relative + * to this one is NOT constrained: dshClient.inject edges are informational + * (loading/prefetch metadata, never apply sequencing) and neither owner + * provides a waitable service. apply therefore registers via + * declaration-aware deferral instead of assuming order. */ -export const inject = ['slots', 'workspaces'] +export const inject = ['slots', 'sessions', 'workspaces'] /** - * Register WorkspacePicker in both owner slots once their declarations are on - * the ledger. The inject factory returns a plain Workspace creation callback; - * data reads use the framework's global useWorkspaces hook. + * Register the browser and picker once their slot declarations are on the + * ledger. Inject factories return plain callbacks; data reads use the + * framework's global hooks. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - const injected = (): WorkspacePickerInjected => ({ + const browserInjected = (): WorkspaceBrowserInjected => ({ + startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) }, + open: (sessionId) => { ctx.sessions.open(sessionId) }, + renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, + insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => { + await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + }, createWorkspace: input => ctx.workspaces.create(input), }) - // Declaration-aware registration: the sidebar's declaring apply may - // activate after this one (entry activation order is unconstrained), and a - // register into an undeclared slot throws. Register once the declaration - // is on the ledger; the subscription also re-registers after an HMR - // collapse re-declares the slot (the cascade disposed our entry with it). + const pickerInjected = (): WorkspacePickerInjected => ({ + createWorkspace: input => ctx.workspaces.create(input), + }) + // Declaration-aware registration: each owner's declaring apply may activate + // after this one (entry activation order is unconstrained), and a register + // into an undeclared slot throws. Register once the declaration is on the + // ledger; the subscription also re-registers after an HMR collapse + // re-declares the slot (the cascade disposed our entry with it). ctx.effect(() => { - const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const - const disposers = new Map<(typeof slotNames)[number], () => void>() - const tryRegister = (name: (typeof slotNames)[number]): void => { - if (ctx.slots.spec(name) === undefined) return - if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return - disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker)) + const registrations = [ + { + name: 'sidebar.workspaces' as const, + component: WorkspaceBrowser, + register: () => ctx.slots.register( + { name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected }, + WorkspaceBrowser, + ), + }, + { + name: 'conversation.empty.workspace' as const, + component: WorkspacePicker, + register: () => ctx.slots.register( + { name: 'conversation.empty.workspace', inject: pickerInjected }, + WorkspacePicker, + ), + }, + ] + const disposers = new Map<string, () => void>() + const tryRegister = (entry: (typeof registrations)[number]): void => { + if (ctx.slots.spec(entry.name) === undefined) return + if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return + disposers.set(entry.name, entry.register()) } - const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) })) - for (const name of slotNames) tryRegister(name) + const unsubscribers = registrations.map(entry => + ctx.slots.subscribe(entry.name, () => { tryRegister(entry) })) + for (const entry of registrations) tryRegister(entry) return () => { for (const unsubscribe of unsubscribers) unsubscribe() for (const dispose of disposers.values()) dispose() } - }, 'ui-workspace: picker registrations') + }, 'ui-workspace: browser + picker registrations') } diff --git a/packages/client/ui-sidebar/src/client/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css similarity index 79% rename from packages/client/ui-sidebar/src/client/Rows.module.css rename to packages/client/ui-workspace/src/client/rows/Rows.module.css index f996f03348..7b19284b66 100644 --- a/packages/client/ui-sidebar/src/client/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -150,14 +150,63 @@ } .projectRow:hover .rowActions, -.sessionRow:hover .rowActions { +.sessionRow:hover .rowActions, +.projectRow.menuOpen .rowActions, +.sessionRow.menuOpen .rowActions { display: inline-flex; } -.sessionRow:hover .time { +.sessionRow:hover .time, +.sessionRow.menuOpen .time { display: none; } +/* An open row menu pins the hover affordances (figma: the row keeps its + hover fill while its dropdown is up). */ +.projectRow.menuOpen, +.sessionRow.menuOpen { + background: var(--dsw-alias-interactive-bg-hover); +} + +/* Drag reorder insert line (workspace-group roots): 2px accent above or + below the hovered row, drawn with box-shadow so no layout shift. */ +.sessionRow.dropBefore { + box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary); +} + +.sessionRow.dropAfter { + box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary); +} + +/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */ +.hoverContent { + display: flex; + flex-direction: column; + gap: 8px; +} + +.hoverTitle { + font-size: 14px; + line-height: 20px; + color: #FFFFFF; + overflow-wrap: break-word; +} + +.hoverTime { + font-size: 12px; + line-height: 16px; + color: #CFD3D6; +} + +.hoverStatus { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + line-height: 20px; + color: #ADB2B8; +} + .iconButton { flex: none; display: inline-flex; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx new file mode 100644 index 0000000000..5a3f923be0 --- /dev/null +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -0,0 +1,285 @@ +/** + * Workspace browser tree row components (figma Cell set 14:3080): pure presentational — + * all data and callbacks arrive via props. Hover swaps (folder->chevron, + * time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only + * except workspace Rename; the session hover card is suppressed while a menu + * is open. + */ +import { useState } from 'react' +import clsx from 'clsx' +import { + HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16, + IconFolderClose16, IconFolderOpen16, IconPlusOutline16, + IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { GroupNode, SessionNode } from '../tree.ts' +import { formatRelativeTime } from '../tree.ts' +import css from './Rows.module.css' + +/** Indent step per tree level: one 16px slot (figma session cell). */ +const INDENT_STEP = 16 + +const SESSION_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> }, + { id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> }, + { id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true }, +] + +const WORKSPACE_MENU_ITEMS = [ + { id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> }, + { id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true }, +] + +/** + * Project (workspace) header row: 54px, folder + title + session count; + * hover reveals the chevron and create button. `containsCurrent` arrives on + * the node (derivation fact, no renderer scan). + * @param props.group - derived group node. + * @param props.onToggle - expand/collapse the group. + * @param props.onCreate - start a frontend Session inside this Workspace. + * @returns the row element. + */ +export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { + group: GroupNode + onToggle: () => void + onCreate: () => void + /** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */ + onRename?: (() => void) | undefined +}) { + const row = group + const active = group.expanded && group.containsCurrent + const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}` + const [menuOpen, setMenuOpen] = useState(false) + return ( + <div + className={clsx(css.projectRow, menuOpen && css.menuOpen)} + role="treeitem" + aria-expanded={row.expanded} + onClick={onToggle} + > + <span className={clsx(css.slot, css.folder, active && css.folderActive)}> + {row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />} + </span> + <span className={clsx(css.slot, css.chevron)}> + <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> + </span> + <span className={css.projectText}> + <span className={css.title}>{row.label}</span> + <span className={css.meta}>{count}</span> + </span> + <span className={css.rowActions}> + {onRename !== undefined && ( + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + items={WORKSPACE_MENU_ITEMS} + onSelect={(id) => { + setMenuOpen(false) + if (id === 'rename') onRename() + // Delete is visual-only for now. + }} + portal + closeOnPointerLeave + anchor={( + <button + type="button" + className={css.iconButton} + aria-label={`Workspace actions for ${row.label}`} + onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }} + > + <IconEllipsisOutline16 /> + </button> + )} + /> + )} + <button + type="button" + className={css.iconButton} + aria-label={`New session in ${row.label}`} + onClick={(e) => { e.stopPropagation(); onCreate() }} + > + <IconPlusOutline16 /> + </button> + </span> + </div> + ) +} + +/** + * The selected "New session" row for a frontend Session Intent targeted to a + * real Workspace. The row disappears when the Intent is replaced or connects. + * @param props.flat - flat-list variant: no twist slot (figma flat cell), so + * only the status slot indents the title. + * @returns the placeholder row element. + */ +export function IntentRowItem({ flat = false }: { flat?: boolean } = {}) { + return ( + <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}> + {!flat && <span className={css.slot} />} + <span className={css.slot} /> + <span className={css.title}>New session</span> + </div> + ) +} + +/** + * One session subtree: the node's own 34px row (indent by depth, expand + * twist when it has children, running dot, relative time) plus its visible + * children, recursively — the component tree mirrors the derived tree. + * @param props.node - derived session node. + * @param props.depth - 0 = directly under the group header. + * @param props.currentId - selected session id (row highlight). + * @param props.now - epoch ms for relative-time formatting. + * @param props.onOpen - open a session by id. + * @param props.onToggle - unfold/fold a subtree by id. + * @returns the node's row followed by its children. + */ +/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */ +function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) { + return ( + <div className={css.hoverContent}> + <div className={css.hoverTitle}>{node.title}</div> + <div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div> + <div className={css.hoverStatus}> + <StateDot state={node.running ? 'ongoing' : 'done'} /> + <span>{node.running ? 'Running' : 'Idle'}</span> + </div> + </div> + ) +} + +/** + * Root-row drag wiring supplied by the group owner (workspace groups only). + * `drop` reports the half of the row the pointer released on: 'before' + * inserts above this row, 'after' below it (the owner resolves the anchor). + */ +export interface RowDragProps { + /** Start dragging this row. */ + start: () => void + /** A drag from the same group is in flight (rows show insert markers). */ + active: boolean + /** Current marker on this row: insert line above, below, or none. */ + marker: 'before' | 'after' | null + /** Report the hovered half while a same-group drag passes over this row. */ + hover: (half: 'before' | 'after') => void + drop: (half: 'before' | 'after') => void + end: () => void +} + +/** Pointer-position half of a row (insert line above or below). */ +function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' { + const rect = e.currentTarget.getBoundingClientRect() + return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after' +} + +export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: { + node: SessionNode + depth: number + currentId: string | undefined + now: number + onOpen: (id: SessionNode['id']) => void + onToggle: (id: SessionNode['id']) => void + /** Present only on draggable rows (workspace-group roots outside search). */ + drag?: RowDragProps | undefined + /** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */ + flat?: boolean +}) { + const row = node + const selected = node.id === currentId + const [menuOpen, setMenuOpen] = useState(false) + // Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to + // the title): both slots are always reserved so titles align whether or not + // the twist/dot is lit. Extra depth rides the left padding. + const ownRow = ( + <div + className={clsx( + css.sessionRow, selected && css.selected, menuOpen && css.menuOpen, + drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter, + )} + role="treeitem" + aria-selected={selected} + {...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})} + style={{ paddingLeft: 8 + depth * INDENT_STEP }} + onClick={() => { onOpen(node.id) }} + draggable={drag !== undefined} + onDragStart={drag === undefined + ? undefined + : (e) => { + e.dataTransfer.effectAllowed = 'move' + drag.start() + }} + onDragEnd={drag?.end} + onDragOver={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + drag.hover(rowHalf(e)) + }} + onDrop={drag === undefined + ? undefined + : (e) => { + if (!drag.active) return + e.preventDefault() + drag.drop(rowHalf(e)) + }} + > + {row.hasChildren && !flat + ? ( + <button + type="button" + className={css.twist} + aria-label={row.expanded ? 'Collapse' : 'Expand'} + onClick={(e) => { e.stopPropagation(); onToggle(node.id) }} + > + <IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} /> + </button> + ) + : null} + <span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span> + <span className={css.title}>{row.title}</span> + <span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span> + <span className={css.rowActions}> + <Menu + open={menuOpen} + onClose={() => { setMenuOpen(false) }} + items={SESSION_MENU_ITEMS} + onSelect={() => { setMenuOpen(false) }} // Visual-only for now. + portal + closeOnPointerLeave + anchor={( + <button + type="button" + className={css.iconButton} + aria-label={`Session actions for ${row.title}`} + onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }} + > + <IconEllipsisOutline16 /> + </button> + )} + /> + </span> + </div> + ) + return ( + <> + <HoverCard + anchor={ownRow} + content={<SessionHoverContent node={node} now={now} />} + disabled={menuOpen || drag?.active === true} + /> + {node.children.map(child => ( + <SessionNodeItem + key={child.id} + node={child} + depth={depth + 1} + currentId={currentId} + now={now} + onOpen={onOpen} + onToggle={onToggle} + /> + ))} + </> + ) +} diff --git a/packages/client/ui-workspace/src/client/stores.ts b/packages/client/ui-workspace/src/client/stores.ts new file mode 100644 index 0000000000..ed89d80d9e --- /dev/null +++ b/packages/client/ui-workspace/src/client/stores.ts @@ -0,0 +1,36 @@ +/** + * The workspace browser's viewing store: the session-list grouping mode, + * persisted across reloads. Module level exports the factory only (a + * module-level handle would pin the store identity across plugin reloads); + * register() receives the factory and the browser derives its PropsStore + * share from the return type. + */ +import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client' + +/** Session-list grouping mode: workspace sections or one flat recency list. */ +export type WorkspaceGroupBy = 'workspace' | 'flat' + +/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */ +type WorkspaceViewState = { groupBy: WorkspaceGroupBy } + +/** + * Annotation twin of the actions literal below (the export needs a declared + * return type); drift fails assignability at the defineStore call. + */ +type WorkspaceViewActions = { + setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void +} + +/** + * Create the workspace browser viewing store handle. + * @returns the store handle (spec + type + identity + factory in one). + */ +export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> { + return defineStore({ + init: (): WorkspaceViewState => ({ groupBy: 'workspace' }), + persist: 'dsh.workspace.view', + actions: { + setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode }, + }, + }) +} diff --git a/packages/client/ui-sidebar/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts similarity index 89% rename from packages/client/ui-sidebar/src/client/tree.ts rename to packages/client/ui-workspace/src/client/tree.ts index 64fda519e6..39a479f3b3 100644 --- a/packages/client/ui-sidebar/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -1,5 +1,5 @@ /** - * Derives the sidebar tree from Host Workspace order and membership. + * Derives the workspace browser tree from Host Workspace order and membership. * Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render. */ import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client' @@ -223,11 +223,11 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] { } /** - * Derive the nested sidebar group structure. + * Derive the nested workspace browser group structure. * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps @@ -263,7 +263,9 @@ export function deriveGroups( && g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId const intentHere = q === '' && hasIntent if (q === '') { - const expanded = intentHere || expandedProjects.has(g.key) + // The intent never forces expansion — the viewer auto-expands the + // target group once (current-group effect); the toggle stays live. + const expanded = expandedProjects.has(g.key) groups.push({ key: g.key, workspaceId: g.workspaceId, @@ -294,6 +296,29 @@ export function deriveGroups( return groups } +/** + * Derive the flat session list ("In one list" mode): every session — fork + * children included — as a top-level row, strictly newest-first. No grouping, + * no parent/child adjacency; rows reuse SessionNode with children always + * empty so the renderer stays branch-free. Search mode filters by + * case-insensitive display-title substring. + * @param list - sessions list snapshot. + * @param view - the search query (expansion state does not apply). + * @returns flat rows in render order. + */ +export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] { + const q = view.query.trim().toLowerCase() + const rows: SessionSummary[] = [] + for (const id of list.ids) { + const s = list.byId[id] + if (s === undefined) continue + if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue + rows.push(s) + } + rows.sort(byRecency) + return rows.map(s => sessionNode(s, [], false, false)) +} + /** * Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y"). * @param updatedAt - epoch ms of the session's last activity. diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 9352caa0b4..6e1c7a3a3f 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -2,7 +2,8 @@ import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' -import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx' async function bench() { @@ -13,32 +14,33 @@ async function bench() { path: 'name' in input ? `/projects/${input.name}` : input.path, title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0', })) - ctx.provide('workspaces', { create }) - return { ctx, slots: ctx.get('slots') as SlotsService, create } + const startSession = vi.fn() + const rename = vi.fn(async () => ({})) + const insertSessionBefore = vi.fn(async () => ({})) + const open = vi.fn() + ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never) + ctx.provide('sessions', { open } as never) + return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open } } -function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void { - return slots.register( - { name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never, - () => null, - ) -} +type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace' -function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected { - const entry = slots.entries(name)[0]! - return (entry.inject as () => WorkspacePickerInjected)() +/** Declare one or both holes with a single root registration ('root' is a single slot). */ +function declare(slots: SlotsService, ...names: HoleName[]): () => void { + const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }])) + return slots.register({ name: 'root', children } as never, () => null) } describe('ui-workspace apply', () => { - it('declares the independent Workspace service', () => { - expect(inject).toEqual(['slots', 'workspaces']) + it('declares the services it drives', () => { + expect(inject).toEqual(['slots', 'sessions', 'workspaces']) }) - it('registers the shared picker for declarations that arrive before or after apply', async () => { + it('registers browser and picker for declarations arriving before or after apply', async () => { const before = await bench() - declare(before.slots, 'sidebar.workspace') + declare(before.slots, 'sidebar.workspaces') await before.ctx.plugin({ inject: [...inject], apply }).await() - expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker) + expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser) const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() @@ -47,23 +49,35 @@ describe('ui-workspace apply', () => { expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker) }) - it('routes name and path creation to WorkspacesService', async () => { + it('routes browser actions and picker creation to the services', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') await b.ctx.plugin({ inject: [...inject], apply }).await() - const injected = injectedOf(b.slots, 'sidebar.workspace') - await injected.createWorkspace({ name: 'project' }) - await injected.createWorkspace({ path: '/tmp/project' }) - expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' }) - expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' }) + + const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() + browser.startSession('ws' as never, 'prompt') + expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt') + browser.open('session' as never) + expect(b.open).toHaveBeenCalledWith('session') + await browser.renameWorkspace('ws' as never, 'renamed') + expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') + await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never) + expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2') + await browser.createWorkspace({ name: 'project' }) + expect(b.create).toHaveBeenCalledWith({ name: 'project' }) + + const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)() + await picker.createWorkspace({ path: '/tmp/project' }) + expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' }) }) - it('unregisters picker entries on teardown', async () => { + it('unregisters both entries on teardown', async () => { const b = await bench() - declare(b.slots, 'sidebar.workspace') + declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace') const fiber = b.ctx.plugin({ inject: [...inject], apply }) await fiber.await() await fiber.dispose() - expect(b.slots.entries('sidebar.workspace')).toHaveLength(0) + expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0) + expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0) }) }) diff --git a/packages/client/ui-sidebar/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx similarity index 97% rename from packages/client/ui-sidebar/tests/rows.spec.tsx rename to packages/client/ui-workspace/tests/rows.spec.tsx index 468ce550d9..fdc5c43b8e 100644 --- a/packages/client/ui-sidebar/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' -import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx' +import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' afterEach(cleanup) @@ -10,7 +10,7 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId -describe('sidebar rows', () => { +describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() const onCreate = vi.fn() diff --git a/packages/client/ui-sidebar/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts similarity index 100% rename from packages/client/ui-sidebar/tests/tree.spec.ts rename to packages/client/ui-workspace/tests/tree.spec.ts diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 2b76d407f7..200e0811ce 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -934,10 +934,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'list(): Workspace[]', jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */', }, - { - signature: 'async touchSession(sessionId: SessionId): Promise<void>', - jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */', - }, { signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>', jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */', @@ -2498,7 +2494,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'Workspace', - declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', + declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}', }, ] diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 201bc555a9..d50dd000e1 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' import { - workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError, + workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, + WorkspaceMoveInvalidError, WorkspaceNameConflictError, } from '@deepseek-ai/dsh-workspace' // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters). import type {} from '@deepseek-ai/dsh-tools' @@ -680,6 +681,72 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } }, + async rename(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + const title = payload.title.trim() + // Uniqueness AND the same-title no-op both ride the create chain so + // they observe the state left by earlier queued renames — checked + // up front, a queued A→A could report success while an earlier A→B + // still lands afterwards. + const operation = workspaceCreationChain.then(async () => { + if (title === workspace.title) return + if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) { + throw new WorkspaceNameConflictError(title) + } + await workspace.setTitle(title) + }) + workspaceCreationChain = operation.then(() => undefined, () => undefined) + try { + await operation + } catch (error: unknown) { + if (error instanceof WorkspaceNameConflictError) { + return err(request, { + code: 'workspace-name-conflict', + message: error.message, + details: { name: error.workspaceName }, + }) + } + throw error + } + return ok(request, { workspace: workspaceView(workspace) }) + }, + + async insertSessionBefore(request) { + const { payload } = request + const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) + if (workspace === undefined) { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${payload.workspaceId}" not found`, + details: { workspaceId: payload.workspaceId }, + }) + } + try { + await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) + } catch (error: unknown) { + // Only the entity's unaccounted-id rejection is the business code; + // storage/durability failures propagate as internal errors. + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + return err(request, { + code: 'workspace-move-invalid', + message: error.message, + details: { + workspaceId: payload.workspaceId, + sessionId: payload.sessionId, + ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId }, + }, + }) + } + return ok(request, { workspace: workspaceView(workspace) }) + }, }, host: { diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index 1f0f9acef4..68b6289858 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -19,6 +19,8 @@ export interface RpcMethodMap { 'host.describe': HostApi['describe'] 'workspace.list': WorkspaceApi['list'] 'workspace.create': WorkspaceApi['create'] + 'workspace.rename': WorkspaceApi['rename'] + 'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore'] } /** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */ diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 3b290e18c3..d83ae2ce98 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code', z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }), z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }), z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }), + z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }), ]) as unknown as z.ZodType<RpcError> diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index dbcc975d04..ad06c42fbe 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap { 'workspace-not-found': { workspaceId: string } 'workspace-invalid-path': { path: string } 'workspace-name-conflict': { name: string } + 'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId } 'agent-busy': { reason: string } 'internal': {} } diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index a193fb0c57..47c3ae6d59 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({ workspace: workspaceViewSchema, created: z.boolean(), }) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>> + +/** workspace.rename request payload: the new title must be non-blank. */ +export const workspaceRenameRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + title: z.string(), +}).refine( + payload => payload.title.trim() !== '', + { message: 'workspace.rename requires a non-blank title' }, +) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>> + +/** workspace.rename response value. */ +export const workspaceRenameValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>> + +/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */ +export const workspaceInsertSessionBeforeRequestSchema = z.object({ + workspaceId: workspaceIdSchema, + sessionId: sessionIdSchema, + beforeSessionId: sessionIdSchema.optional(), +}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>> + +/** workspace.insertSessionBefore response value. */ +export const workspaceInsertSessionBeforeValueSchema = z.object({ + workspace: workspaceViewSchema, +}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>> diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index 86c20e2ff5..6ec636126b 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -24,7 +24,10 @@ export interface WorkspaceView { path: string /** Unique display title (defaults to the path basename at create). */ title: string - /** Sessions accounted under this workspace, newest-first for display. */ + /** + * Sessions accounted under this workspace, in manually owned order + * (attach prepends, insertSessionBefore reorders; activity never does). + */ sessionIds: SessionId[] /** ISO-8601 creation instant. */ createdAt: string @@ -52,4 +55,27 @@ export interface WorkspaceApi { */ create(request: RpcRequest<{ path?: string; name?: string }>): Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> + + /** + * Renames a workspace. `title` is trimmed and must be non-empty + * (schema-enforced). An unknown id fails with `workspace-not-found`; a + * title equal to another workspace's fails with `workspace-name-conflict`. + * Renaming to the current title is a no-op success (no durable write). + */ + rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>): + Promise<RpcResponse<{ workspace: WorkspaceView }>> + + /** + * Moves an accounted session within its workspace's manual order, + * DOM-insertBefore-like: with `beforeSessionId` the session is inserted + * before that anchor; omitted appends to the end. An unknown workspace + * fails with `workspace-not-found`; a session or anchor not accounted by + * the workspace fails with `workspace-move-invalid`. A move to the current + * position is a no-op success. + */ + insertSessionBefore(request: RpcRequest<{ + workspaceId: WorkspaceId + sessionId: SessionId + beforeSessionId?: SessionId + }>): Promise<RpcResponse<{ workspace: WorkspaceView }>> } diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index 81749fc219..91c4405ace 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -23,7 +23,9 @@ import { } from '../api/sessions.schema.ts' import { workspaceCreateValueSchema, + workspaceInsertSessionBeforeValueSchema, workspaceListValueSchema, + workspaceRenameValueSchema, } from '../api/workspace.schema.ts' /** @@ -55,6 +57,8 @@ export interface IApiClient { workspace: { list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>> create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>> + rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>> + insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>> } events: { mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>> @@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV 'host.describe': hostDescribeValueSchema, 'workspace.list': workspaceListValueSchema, 'workspace.create': workspaceCreateValueSchema, + 'workspace.rename': workspaceRenameValueSchema, + 'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema, } /** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */ @@ -266,6 +272,8 @@ export abstract class AbstractApiClient implements IApiClient { readonly workspace: IApiClient['workspace'] = { list: (payload, signal) => this.callUnary('workspace.list', payload, signal), create: (payload, signal) => this.callUnary('workspace.create', payload, signal), + rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal), + insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal), } readonly events: IApiClient['events'] = { diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index e876d664b2..91762810e8 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -24,7 +24,9 @@ import { import { hostDescribeRequestSchema } from '../api/host.schema.ts' import { workspaceCreateRequestSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceListRequestSchema, + workspaceRenameRequestSchema, } from '../api/workspace.schema.ts' /** @@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = { 'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) }, 'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) }, 'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) }, + 'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) }, + 'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) }, } /** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */ diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 7dec5980eb..25b095b99f 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -37,6 +37,8 @@ function scriptedApi(overrides: { workspace: { list: r => ok(r, { items: [] }), create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }), + rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), + insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }), }, events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ede6acb9ac..e4b1d33e87 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } }, } }, + async rename(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, + async insertSessionBefore(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } }, + } + }, }, events: { mux: (_request, signal) => stream(muxFrames, signal), diff --git a/packages/workspace/workspace/src/entity.ts b/packages/workspace/workspace/src/entity.ts index 7f213db0b8..1df3eacb3d 100644 --- a/packages/workspace/workspace/src/entity.ts +++ b/packages/workspace/workspace/src/entity.ts @@ -15,6 +15,17 @@ import type { WorkspaceRecord } from './spec.ts' import type { Workspace, WorkspaceId } from './types.ts' import { realpathNormalize } from './paths.ts' +/** An insertSessionBefore request named a session or anchor not on the account (storage failures stay plain errors). */ +export class WorkspaceMoveInvalidError extends Error { + /** + * @param message - Which id was unaccounted and where. + */ + constructor(message: string) { + super(message) + this.name = 'WorkspaceMoveInvalidError' + } +} + /** * The registry-owned machinery an entity mutates through. Entities never see * the registry itself — only the open table, the canonical session-path @@ -137,30 +148,27 @@ export class WorkspaceEntity implements Workspace { : { ...record, sessionIds: [sessionId, ...record.sessionIds] }) } - /** - * Test the durable candidate account without applying header projection. - * @param sessionId - Candidate session id. - * @returns whether this workspace's stored account contains the id. - */ - hasSession(sessionId: SessionId): boolean { - return this.record.sessionIds.includes(sessionId) - } - - /** - * Move one validated accounted session to the front without touching peers. - * @param sessionId - Accounted session whose activity was observed. - */ - async touchSession(sessionId: SessionId): Promise<void> { - if ( - this.host.sessionPath(sessionId) !== this.record.path - || this.record.sessionIds[0] === sessionId - ) return - await this.mutate(record => !record.sessionIds.includes(sessionId) || record.sessionIds[0] === sessionId - ? record - : { - ...record, - sessionIds: [sessionId, ...record.sessionIds.filter(id => id !== sessionId)], - }) + async insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> { + await this.mutate((record) => { + if (!record.sessionIds.includes(sessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' in workspace '${record.path}': the session is not accounted`, + ) + } + if (beforeSessionId !== undefined && !record.sessionIds.includes(beforeSessionId)) { + throw new WorkspaceMoveInvalidError( + `cannot move session '${sessionId}' before '${beforeSessionId}' in workspace '${record.path}': ` + + 'the anchor session is not accounted', + ) + } + if (beforeSessionId === sessionId) return record + const without = record.sessionIds.filter(id => id !== sessionId) + const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) + const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] + return sessionIds.every((id, index) => id === record.sessionIds[index]) + ? record + : { ...record, sessionIds } + }) } async detachSession(sessionId: SessionId): Promise<void> { diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index c20c2143c6..0f849e7374 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -14,6 +14,8 @@ import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceEntity } from './entity.ts' import type { WorkspaceEntityHost } from './entity.ts' + +export { WorkspaceMoveInvalidError } from './entity.ts' import { realpathNormalize } from './paths.ts' import { workspaceDomainSpec } from './spec.ts' import type { WorkspaceDomainState, WorkspaceRecord } from './spec.ts' @@ -47,6 +49,7 @@ export class WorkspaceNameConflictError extends Error { } } + declare module 'cordis' { interface Context { workspace: WorkspaceRegistry @@ -82,7 +85,6 @@ export class WorkspaceRegistry extends Service { private readonly headers = new Map<SessionId, SessionHeader>() private readonly sessionPaths = new Map<SessionId, string>() private readonly invalidSessionPaths = new Map<SessionId, string>() - private readonly pendingTouches = new Map<SessionId, Promise<void>>() private operationTail: Promise<void> = Promise.resolve() private readonly host: WorkspaceEntityHost = { @@ -120,13 +122,6 @@ export class WorkspaceRegistry extends Service { this.validateStoredState(this.requireState()) this.rebuildEntities() this.reportFilteredCandidates() - // Session activity is authoritative even when no RPC/SSE consumer is - // connected. This service-owned listener is disposed with the registry. - this.ctx.on('session/event', (session) => { - void this.touchSession(session.id).catch((error: unknown) => { - this.ctx.logger.warn(`workspace activity touch failed for session '${session.id}': ${String(error)}`) - }) - }) } /** @@ -173,32 +168,6 @@ export class WorkspaceRegistry extends Service { }) } - /** - * Move one accounted, cwd-validated session to the front of its workspace. - * Ungrouped sessions and candidates filtered by the header check are - * no-ops. The owning workspace's relative position never changes. - * @param sessionId - Session whose activity was observed. - * @returns resolution after the possible record write. - */ - async touchSession(sessionId: SessionId): Promise<void> { - const pending = this.pendingTouches.get(sessionId) - if (pending !== undefined) { - await pending - return - } - for (const entity of this.entities.values()) { - if (!entity.hasSession(sessionId)) continue - const touch = entity.touchSession(sessionId) - this.pendingTouches.set(sessionId, touch) - try { - await touch - } finally { - this.pendingTouches.delete(sessionId) - } - return - } - } - /** * Resolve by canonical directory path without creating or mutating a * workspace. A missing path rejects during `realpath`; an existing unowned diff --git a/packages/workspace/workspace/src/types.ts b/packages/workspace/workspace/src/types.ts index ca254ca2cb..09d37213cc 100644 --- a/packages/workspace/workspace/src/types.ts +++ b/packages/workspace/workspace/src/types.ts @@ -41,10 +41,12 @@ export interface Workspace { readonly updatedAt: string /** - * Header-validated sessions in newest-first display order. The durable - * candidate account is filtered synchronously: missing headers, invalid - * cwd values, and canonical cwd mismatches are never returned. A subsequent - * workspace mutation prunes those filtered candidates durably. + * Header-validated sessions in manually owned order: a new session is + * prepended at attach, explicit reordering goes through + * `insertSessionBefore`, and activity never reorders. The durable candidate + * account is filtered synchronously: missing headers, invalid cwd values, + * and canonical cwd mismatches are never returned. A subsequent workspace + * mutation prunes those filtered candidates durably. */ readonly sessionIds: readonly SessionId[] @@ -57,8 +59,7 @@ export interface Workspace { /** * Prepend a session to this workspace's candidate account. An already - * accounted id resolves without writing; activity-driven reordering uses - * `WorkspaceRegistry.touchSession` instead. A new id's live or persisted + * accounted id resolves without writing. A new id's live or persisted * header cwd must resolve to an existing directory equal to {@link path}; * unknown ids, missing or invalid cwd values, and mismatches reject without * writing. @@ -67,6 +68,18 @@ export interface Workspace { */ attachSession(sessionId: SessionId): Promise<void> + /** + * Move an accounted session within the manual order, DOM-insertBefore-like: + * with an anchor the session lands before it, without one it appends to the + * end. Only the moved id changes position. A session or anchor absent from + * the account rejects without writing; a move to the current position + * resolves without writing (decided on the domain write chain). + * @param sessionId - The accounted session to move. + * @param beforeSessionId - Accounted anchor to insert before; omitted appends. + * @returns resolution after durability. + */ + insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void> + /** * Remove a session from this workspace's account. Idempotent: an id not on * the account resolves without writing (decided on the domain write chain, diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index cde0b35098..bdefc2ec82 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,6 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import { WorkspaceEntity } from '../src/entity.ts' import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' @@ -434,13 +433,12 @@ describe('WorkspaceRegistry create and lookup', () => { }) describe('Workspace session ordering', () => { - it('prepends new attaches, keeps repeat attach idempotent, and touches one id only', async () => { + it('prepends new attaches and keeps repeat attach idempotent', async () => { const dir = await makeDir('attach-order') const result = await harness() result.setSessions([ header('s1', dir, 1), header('s2', dir, 2), - header('ungrouped', dir, 3), ]) const workspace = await result.registry.create(dir) await workspace.attachSession(SessionId('s1')) @@ -448,59 +446,7 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s2', 's1']) await workspace.attachSession(SessionId('s1')) expect(workspace.sessionIds).toEqual(['s2', 's1']) - - const beforeTouch = result.changes.length - await Promise.all([ - result.registry.touchSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - expect(workspace.sessionIds).toEqual(['s1', 's2']) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('s1')) - expect(result.changes).toHaveLength(beforeTouch + 1) - await result.registry.touchSession(SessionId('ungrouped')) - expect(result.changes).toHaveLength(beforeTouch + 1) - expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2']) - }) - - it('does not resurrect a session detached before its queued touch', async () => { - const dir = await makeDir('detach-touch-race') - const result = await harness({ sessions: [header('s1', dir), header('s2', dir)] }) - const workspace = await result.registry.create(dir) - await workspace.attachSession(SessionId('s1')) - await workspace.attachSession(SessionId('s2')) - await Promise.all([ - workspace.detachSession(SessionId('s1')), - result.registry.touchSession(SessionId('s1')), - ]) - const written = result.changes.length - await workspace.detachSession(SessionId('absent')) - expect(result.changes).toHaveLength(written) - expect(workspace.sessionIds).toEqual(['s2']) - }) - - it('does not reinsert a candidate absent at the durable touch slot', async () => { - const dir = await makeDir('stale-touch') - const id = WorkspaceId('00000000-0000-4000-8000-000000000030') - let durable = record(dir, ['s2', 's1']) - const table = { - update: async ( - _id: WorkspaceId, - update: (current: WorkspaceRecord) => WorkspaceRecord, - ): Promise<WorkspaceRecord> => { - durable = { ...durable, sessionIds: [SessionId('s2')] } - durable = update(durable) - return durable - }, - } - const entity = new WorkspaceEntity({ - table: () => table as never, - sessionPath: () => dir, - readSessionHeader: async () => header('s1', dir), - rememberSessionPath: () => {}, - }, id, record(dir, ['s2', 's1'])) - await entity.touchSession(SessionId('s1')) - expect(durable.sessionIds).toEqual(['s2']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) it('validates a lazy live session without requiring it in persistence.list()', async () => { @@ -546,66 +492,6 @@ describe('Workspace session ordering', () => { expect(workspace.sessionIds).toEqual(['s1']) }) - it('keeps workspace order stable while touch order survives reload', async () => { - const older = await makeDir('stable-older') - const newer = await makeDir('stable-newer') - const sessions = [ - header('old-1', older, 100), - header('old-2', older, 200), - header('new-1', newer, 300), - ] - const pool = new MemoryMediaPool() - const first = await harness({ pool, sessions }) - const originalWorkspaceIds = first.registry.list().map(workspace => workspace.id) - const oldWorkspace = first.registry.list().find(workspace => workspace.path === older)! - expect(oldWorkspace.sessionIds).toEqual(['old-2', 'old-1']) - await first.registry.touchSession(SessionId('old-1')) - expect(oldWorkspace.sessionIds).toEqual(['old-1', 'old-2']) - expect(first.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - await first.fiber.dispose() - - const reloaded = await harness({ pool, sessions }) - expect(reloaded.registry.list().map(workspace => workspace.id)).toEqual(originalWorkspaceIds) - expect(reloaded.registry.list().find(workspace => workspace.path === older)!.sessionIds) - .toEqual(['old-1', 'old-2']) - }) - - it('persists activity order from session/event without any stream consumer', async () => { - const dir = await makeDir('event-touch') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('event-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('event-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('event-first')) - await workspace.attachSession(SessionId('event-second')) - expect(workspace.sessionIds).toEqual(['event-second', 'event-first']) - - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(workspace.sessionIds).toEqual(['event-first', 'event-second']) }) - expect(storedRecord(result.pool, workspace.id).sessionIds) - .toEqual(['event-first', 'event-second']) - }) - - it('contains a background activity write failure at the service listener', async () => { - const dir = await makeDir('event-touch-failure') - const result = await harness({ sessionStore: true }) - const workspace = await result.registry.create(dir) - const first = result.ctx.sessions.create(SessionId('failed-first'), { meta: { cwd: dir } }) - result.ctx.sessions.create(SessionId('failed-second'), { meta: { cwd: dir } }) - await workspace.attachSession(SessionId('failed-first')) - await workspace.attachSession(SessionId('failed-second')) - const warn = vi.spyOn(result.ctx.logger, 'warn') - result.pool.failNextWrites = 1 - first.append('turn/start', { - turn: 1, - trigger: { kind: 'message', source: { kind: 'user' } }, - }) - await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith(expect.stringContaining('touch failed')) }) - expect(workspace.sessionIds).toEqual(['failed-second', 'failed-first']) - }) }) describe('header-validated membership projection', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f19dcce27..7301079cd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1014,6 +1014,10 @@ importers: version: 18.3.1 packages/client/ui-workspace: + dependencies: + clsx: + specifier: ^2.0.0 + version: 2.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ From f1b877e3c61ded6f94a1a07bbdb95555297af63f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:11:17 +0800 Subject: [PATCH 58/70] fix(web): review-bot round one + regenerate doc catalogs - rename same-title no-op moves inside the serialized creation chain - insertSessionBefore maps only the typed WorkspaceMoveInvalidError to workspace-move-invalid; storage failures stay internal - workspace upsert rejects snapshots older than the installed projection - flat-mode empty state shows when the query hides the intent row - intent row no longer forces group expansion; header twist stays live - group-by menu rides a portal; menu clicks stop propagating to the row - intent row uses the same single-slot indent in both list modes - regenerate cordis api/catalog + doc graphs --- ...ssion-list-browsing-and-manual-order.zh.md | 60 +++++++++++++++++++ docs/cordis-catalog/services.md | 2 +- .../src/client/WorkspaceBrowser.tsx | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 7 +-- 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md new file mode 100644 index 0000000000..e897903f83 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +[English](2026-07-25-session-list-browsing-and-manual-order.md) | 中文 + +## Problem + +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 + +两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 + +## Decision + +### 平铺视图与浏览态 + +group-by 菜单提供 WorkSpace / In one list 两种模式。平铺模式把所有 session(含 fork 子)一律作为顶层行,严格按 `updatedAt` 新→旧排序,不保持父子相邻;Intent 占位行渲染在列表首行。模式选择持久化在浏览器(`dsh.workspace.view`),刷新保持。 + +### 行交互 + +- session 行悬停 500ms 出详情卡(全名/相对时间/状态行;状态本期只有 running/idle 两态,枚举扩展待 wire 增补 status 字段)。卡片与行菜单互斥:菜单开启或拖拽进行中不出卡。 +- session 行 … 菜单:Rename / Fork session / Delete session,本期纯视觉;workspace 组头 … 菜单:Rename(已接线)/ Delete workspace(纯视觉)。菜单鼠标移出即关。 +- 支撑件:`Menu` 新增 label 条目、danger 行、`closeOnPointerLeave`;新增 `HoverCard`(portal 定位、开启延时、disabled 守卫)。 + +### workspace.rename + +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 host 的 workspace 创建串行链内求值(与 create 共链,并发 create/rename 不能穿插出重名或乱序假成功),冲突回 `workspace-name-conflict`。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 + +### 手动排序:insertSessionBefore 取代活动置顶 + +`session/event` → `touchSession` 活动置顶链整体删除;workspace 账本序改为纯手动拥有——新 session attach 时前插,显式重排走 `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })`(DOM insertBefore 语义:锚给了插锚前,缺省 append 到末尾)。实体只对不在账的 session/锚抛类型化的 `WorkspaceMoveInvalidError`,handler 仅把它映射为业务码 `workspace-move-invalid`,存储故障保持 internal。 + +UI 为组内 root 行的 HTML5 拖拽(仅 workspace 分组、非搜索态;fork 子随父不单独拖)。顺序权威完全在 host:drop 只发 RPC,client 零本地重排,视图靠响应体 upsert 与 changed 帧刷新;失败即无事发生。client 的 upsert 拒绝比已装载投影更旧(`updatedAt`)的快照,防迟到的一元响应回滚更新的帧。 + +### 壳/区域切分 + +ui-sidebar 缩为列几何壳:品牌行、折叠状态机、New Session、Settings,以及一个 `sidebar.workspaces` 洞;壳与区域的契约只有两个事实 `{ wide, expandSidebar }`。ui-workspace 全权拥有浏览区域(section header、搜索、分组树与平铺、全部 workspace 对话框、拖拽)及其 groupBy store;rail 态的搜索/新建图标也归区域,经 `expandSidebar()` 请求壳展开。picker 拆为核心件 `WorkspaceCreateFlow`(区域内直接组件组合)与薄包装 `WorkspacePicker`(继续填 ui-conversation 的 hero 坑);原 `sidebar.workspace` picker 坑与声明感知延迟注册随之删除。 + +## Alternatives considered + +**保留活动置顶、拖拽仅作临时调整** —— 手动序在下一次 session 活动即被打乱,形同虚设;两种排序权威并存无法向用户解释。也考虑过「拖过一次即冻结该 workspace 的活动置顶」的折中,状态多一档、语义更难讲,直接删除更干净。 + +**排序报文用数字下标** —— `{ index }` 在拖拽窗口期会漂移:host 前插新 session(如 Intent 材料化)后同一下标指向别的行。锚点式 insertBefore 对前插与过滤投影天然免疫。 + +**drop 后乐观重排** —— client 先行重排需失败回滚,对象层多一块纠缠态;本地/局域网往返毫秒级,等 host 响应的简单方案肉眼无感。顺序权威单一化(完全信 host)后,前端永不发明顺序。 + +**rename 对话框留在 ui-sidebar(最小改动)** —— 正是问题本身:workspace 域的对话框散落在借来的坑里,每加一个(Delete 确认框将至)都重演跨包接线。评审中先议了「只挪 rename Modal」的中间态,最终裁定整个浏览区域归 ui-workspace,壳只留几何。 + +**平铺模式保持父子相邻成组** —— 与「严格按时间」矛盾(子新于兄则插不进相邻位),且平铺本意就是取消层级;拉平并禁用平铺下的拖拽(无持久化载体)更一致。 + +## Consequences + +- 手动序是唯一的 workspace 账本序权威:用户排好的顺序不再被活动打乱;代价是「最近活跃浮到最上」的行为消失,活跃感知转由行内状态点与时间标签承担。`WorkspaceView.sessionIds` 的 wire 契约随之改为手动序措辞。 +- 壳/区域两事实契约把 workspace 域的后续功能(Delete 确认、跨组移动、Ungrouped 收编)全部收进 ui-workspace 单包;ui-sidebar 不再随 session 列表功能演进。 +- 平铺模式不支持排序与分组入口(建到指定 workspace 需切回分组视图),是拍板接受的范围收窄。 +- session 菜单三项与 workspace Delete 的功能接线、状态枚举扩 wire,留待后续迭代。 + +## Testing + +包级用例覆盖派生(deriveGroups/deriveFlat)、行组件、两处 apply 注册与透传、host 实体移位语义、rename/insertSessionBefore 的 RPC 实现与 fixture 桩;`apps/web` keyless snapshot 回归覆盖装配后的应用;交付验收另以 playwright(chromium headless)过 12 项清单(分组默认、平铺切换与持久化、hover 卡出现与抑制、双菜单、rename 全链、拖拽落盘),并对真 host 直打 wire 验证 rename 成功/重名拒绝/`workspace-move-invalid` 三径。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 474f587230..387ff39c3e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1991,7 +1991,7 @@ list(): Workspace[] async resolveByPath(path: string): Promise<Workspace | undefined> ``` -Source: [`packages/workspace/workspace/src/index.ts:75`](../../packages/workspace/workspace/src/index.ts) +Source: [`packages/workspace/workspace/src/index.ts:78`](../../packages/workspace/workspace/src/index.ts) ## Inherited `ctx` members (cordis core + loader/hmr/timer) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index e7d0cdc38c..57de2cbaf5 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -209,7 +209,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi {rows.length === 0 && !intentRow && ( <div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div> )} - {intentRow && <IntentRowItem flat />} + {intentRow && <IntentRowItem />} {rows.map(node => ( <SessionNodeItem key={node.id} diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 5a3f923be0..1239b87591 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -108,14 +108,13 @@ export function ProjectRowItem({ group, onToggle, onCreate, onRename }: { /** * The selected "New session" row for a frontend Session Intent targeted to a * real Workspace. The row disappears when the Intent is replaced or connects. - * @param props.flat - flat-list variant: no twist slot (figma flat cell), so - * only the status slot indents the title. + * One status-slot indent in both grouped and flat lists (session rows carry + * no twist slot either, so titles align). * @returns the placeholder row element. */ -export function IntentRowItem({ flat = false }: { flat?: boolean } = {}) { +export function IntentRowItem() { return ( <div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}> - {!flat && <span className={css.slot} />} <span className={css.slot} /> <span className={css.title}>New session</span> </div> From ba5c1368710709b5b524f1de3a544bd93c281953 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:30:14 +0800 Subject: [PATCH 59/70] docs(notes): record the session-list browsing and manual-order decisions --- ...n-list-browsing-and-manual-order.i18n.yaml | 6 ++ ...-session-list-browsing-and-manual-order.md | 60 +++++++++++++++++++ ...ssion-list-browsing-and-manual-order.zh.md | 2 +- 3 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml new file mode 100644 index 0000000000..590d9227b8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.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-25-session-list-browsing-and-manual-order.md: 586995bf459aeaee88672863977f7acf2a7061a3 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 432d5167a57d30bc04a0b4faf213e4341f07bd2f diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md new file mode 100644 index 0000000000..586995bf45 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -0,0 +1,60 @@ +# Agent Note: Session List Browsing and Manual Workspace Order + +Status: implemented + +English | [中文](2026-07-25-session-list-browsing-and-manual-order.zh.md) + +## Problem + +[Workspace UI Complete Product Flow](2026-07-25-workspace-ui-product-flow.md) shipped the first form of the grouped session list and explicitly scoped out operations such as Rename and drag ordering. The design file (figma 239-10458 and its companion screens) has since filled in those interactions: the list must switch to an ungrouped flat view, session rows need a hover detail card and an action menu, workspaces need renaming, and sessions need manual ordering inside their group. + +Two existing mechanisms stood in the way. First, the host durably promoted the active session to the front of its workspace account on every `session/event` (activity pinning), so any manual order would be scrambled by the next activity — two ordering authorities cannot coexist. Second, the browsing area was split across two packages: ui-sidebar owned the list, search, and header rows while ui-workspace only borrowed a picker slot for its popover; every new workspace-domain dialog required cross-package wiring, and ownership grew more twisted with each one. + +## Decision + +### Flat view and viewing state + +The group-by menu offers two modes, WorkSpace / In one list. Flat mode renders every session (fork children included) as a top-level row, strictly newest-first by `updatedAt`, with no parent/child adjacency; the Intent placeholder renders as the first row. The mode choice persists in the browser (`dsh.workspace.view`) across reloads. + +### Row interactions + +- Session rows show a detail card after a 500ms hover dwell (full title / relative time / status line; the status line has only running/idle until the wire grows a status field). The card and the row menu are mutually exclusive: no card while a menu is open or a drag is in flight. +- Session-row … menu: Rename / Fork session / Delete session, visual-only this iteration; workspace-header … menu: Rename (wired) / Delete workspace (visual-only). Menus close when the pointer leaves them. +- Supporting primitives: `Menu` gains label entries, danger rows, and `closeOnPointerLeave`; a new `HoverCard` (portaled placement, open delay, disabled guard). + +### workspace.rename + +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the host's serialized workspace-creation chain (shared with create, so concurrent create/rename cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. + +### Manual order: insertSessionBefore replaces activity pinning + +The `session/event` → `touchSession` activity-pinning chain is deleted wholesale; the workspace account order is now manually owned — new sessions prepend at attach, and explicit reordering goes through `workspace.insertSessionBefore({ workspaceId, sessionId, beforeSessionId? })` (DOM insertBefore semantics: with an anchor it inserts before it, omitted appends to the end). The entity throws a typed `WorkspaceMoveInvalidError` only for unaccounted session/anchor ids; the handler maps exactly that to the business code `workspace-move-invalid`, while storage failures stay internal. + +The UI is HTML5 drag on root rows inside a group (workspace grouping only, outside search; fork children ride with their parent and are not draggable). Order authority stays entirely host-side: drop only sends the RPC, the client performs zero local reordering, and the view refreshes from the response upsert and the changed frame; a failed move changes nothing. The client's upsert rejects snapshots older (`updatedAt`) than the installed projection so a late unary response cannot roll back a newer frame. + +### Shell/region split + +ui-sidebar shrinks to the column-geometry shell: brand row, fold state machine, New Session, Settings, and one `sidebar.workspaces` hole; the shell↔region contract is two facts, `{ wide, expandSidebar }`. ui-workspace fully owns the browsing region (section header, search, grouped tree and flat list, every workspace dialog, drag) plus its groupBy store; the rail-state search/new-workspace icons belong to the region too and request shell expansion via `expandSidebar()`. The picker splits into the core `WorkspaceCreateFlow` (composed directly inside the region) and the thin `WorkspacePicker` wrapper (still filling ui-conversation's hero slot); the old `sidebar.workspace` picker slot and its declaration-aware deferral are deleted with it. + +## Alternatives considered + +**Keep activity pinning; treat drag as a transient adjustment** — the manual order would be scrambled by the next session activity, making it a fiction; two coexisting ordering authorities cannot be explained to the user. A middle ground — freeze pinning per workspace after the first drag — adds a state tier with murkier semantics; deleting outright is cleaner. + +**Numeric index in the reorder payload** — `{ index }` drifts during the drag window: after the host prepends a new session (e.g. Intent materialization) the same index points at a different row. Anchor-style insertBefore is naturally immune to prepends and filtered projections. + +**Optimistic reordering on drop** — client-first reordering needs failure rollback, one more entangled state in the object layer; local/LAN round-trips are millisecond-scale, so waiting for the host response is imperceptible. With a single order authority (trust the host completely), the frontend never invents an order. + +**Keep the rename dialog in ui-sidebar (smallest change)** — that is the problem itself: workspace-domain dialogs scattered in a borrowed slot, with each addition (the Delete confirmation is coming) repeating the cross-package wiring. Review first considered moving only the rename modal; the ruling was to give the whole browsing region to ui-workspace and leave the shell geometry-only. + +**Keep parent/child adjacency in flat mode** — contradicts strict recency (a child newer than its parent's sibling cannot slot adjacently), and the flat view's purpose is dropping the hierarchy; flattening fully and disabling drag in flat mode (no persistence carrier) is more consistent. + +## Consequences + +- Manual order is the sole authority over the workspace account: an order the user arranges is never scrambled by activity; the cost is losing float-to-top-on-activity, whose signal now rides the row status dot and time label. The `WorkspaceView.sessionIds` wire contract is reworded to the manual-order semantics. +- The two-fact shell/region contract funnels every future workspace-domain feature (Delete confirmation, cross-group moves, Ungrouped adoption) into the single ui-workspace package; ui-sidebar no longer evolves with session-list features. +- Flat mode supports neither reordering nor a create-in-workspace entry point (switching back to grouped view is required) — an accepted scope reduction. +- Wiring the three session-menu items and workspace Delete, and growing the wire status enum, remain future iterations. + +## Testing + +Package-level suites cover the derivations (deriveGroups/deriveFlat), row components, both apply registrations and passthroughs, host entity move semantics, and the rename/insertSessionBefore RPC implementations with their fixture stubs; the `apps/web` keyless snapshots regress the assembled application; delivery acceptance additionally runs a 12-item playwright (chromium headless) checklist (grouped default, flat switch and persistence, hover-card appearance and suppression, both menus, the full rename chain, drag persistence) and drives the real host over the wire for rename success / duplicate rejection / `workspace-move-invalid`. diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index e897903f83..432d5167a5 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.zh.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 +[Workspace UI 完整产品流](2026-07-25-workspace-ui-product-flow.md)交付了分组 session 列表的首个形态,并把 Rename、拖拽排序等操作明确划出当期范围。设计稿(figma 239-10458 及关联画面)随后补齐了这些交互:列表要能切换成不分组的平铺视图、session 行悬停要出详情卡与操作菜单、workspace 要能改名、组内 session 要能手动排序。 两条既有机制挡在前面。其一,host 在每条 `session/event` 上把活跃 session durable 地提到 workspace 账本最前(活动置顶),任何手动排序都会被下一次活动打乱——两种排序权威不可调和。其二,浏览区域被劈在两个包里:ui-sidebar 拥有列表、搜索和组头行,ui-workspace 只借一个 picker 坑放弹层;每加一个 workspace 域的对话框都要跨包接线,归属越来越拧。 From 9fc8a616a9acc9ca303fdadb41313c7f6b1e0454 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:48:36 +0800 Subject: [PATCH 60/70] test(web): close the per-file coverage gate for the session-list surfaces New and touched sources reach the CI per-file 100% thresholds: HoverCard (timers, placement clamp, disabled guard), Menu label/danger/pointer-leave branches, WorkspaceBrowser (mode switch, search, rail icons, rename dialog, drag), rows and tree derivations, the workspace fixture stubs, the rename/ insertSessionBefore wire rows, and the entity move semantics. HoverCard's position state narrows to {left, top} (equivalent refactor, no behavior change). --- .../client/connection/tests/fixture.spec.ts | 63 +++ .../client/ui-primitives/src/HoverCard.tsx | 19 +- .../client/ui-primitives/tests/atoms.spec.tsx | 45 ++ .../ui-primitives/tests/hover-card.spec.tsx | 148 ++++++ .../src/client/WorkspaceBrowser.tsx | 5 + .../client/ui-workspace/tests/rows.spec.tsx | 177 ++++++- .../client/ui-workspace/tests/tree.spec.ts | 42 +- .../tests/workspace-browser.spec.tsx | 458 ++++++++++++++++++ .../apiproxy/tests/client-handler.spec.ts | 13 + .../host/apiproxy/tests/rpc-schemas.spec.ts | 29 +- .../workspace/tests/workspace.spec.ts | 52 +- 11 files changed, 1037 insertions(+), 14 deletions(-) create mode 100644 packages/client/ui-primitives/tests/hover-card.spec.tsx create mode 100644 packages/client/ui-workspace/tests/workspace-browser.spec.tsx diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 16fa4b4ed6..0baabaf617 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -311,6 +311,60 @@ describe('createFixtureApi', () => { expect(rootPath.result.value.workspace.title).toBe('/') }) + it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => { + const api = createFixtureApi() + const abort = new AbortController() + const seen: HostFrame[] = [] + const consuming = (async () => { + for await (const envelope of api.events.host(req({}), abort.signal)) { + seen.push(envelope.payload) + if (seen.length >= 2) abort.abort() + } + })() + await new Promise(resolve => setTimeout(resolve, 10)) + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) + + await api.workspace.create(req({ name: 'occupied' })) + const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) + expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) + + const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' })) + if (!noop.result.ok) throw new Error('no-op rename failed') + expect(noop.result.value.workspace.title).toBe('fixture') + + const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) + if (!renamed.result.ok) throw new Error('rename failed') + expect(renamed.result.value.workspace.title).toBe('renamed') + await consuming + // Only the create and the effective rename emit frames; the no-op stays silent. + expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + }) + + it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => { + const api = createFixtureApi() + const wsid = 'fx-ws-fixture' as WorkspaceId + const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') })) + expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } }) + const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') })) + expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } }) + const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') })) + expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } }) + + const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') })) + if (!moved.result.ok) throw new Error('move failed') + expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta']) + const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!appended.result.ok) throw new Error('append failed') + expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + const before = appended.result.value.workspace.updatedAt + const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') })) + if (!noop.result.ok) throw new Error('no-op move failed') + expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha']) + expect(noop.result.value.workspace.updatedAt).toBe(before) + }) + it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() const abort = new AbortController() @@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { const workspace = await client.workspace.create({ name: 'via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') + const wsid = workspace.result.value.workspace.workspaceId + const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + if (!renamed.result.ok) throw new Error('workspace rename failed') + expect(renamed.result.value.workspace.title).toBe('via-client-2') + const attached = await client.sessions.create({ workspaceId: wsid }) + if (!attached.result.ok) throw new Error('attached create failed') + const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + if (!moved.result.ok) throw new Error('workspace move failed') + expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) }) it('maps empty, prompt-reject, and workspace-first query scenarios', async () => { diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 58e281778e..1720a0b79c 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -5,7 +5,7 @@ // and closes the instant the pointer leaves the anchor (no close delay). import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode } from 'react' +import type { ReactNode } from 'react' import { createPortal } from 'react-dom' import css from './HoverCard.module.css' @@ -27,7 +27,7 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false const cardRef = useRef<HTMLDivElement>(null) const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const [open, setOpen] = useState(false) - const [pos, setPos] = useState<CSSProperties | null>(null) + const [pos, setPos] = useState<{ left: number; top: number } | null>(null) const clearTimer = () => { if (timerRef.current !== null) { @@ -50,8 +50,10 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false useLayoutEffect(() => { if (!open) { setPos(null); return } const place = () => { - const r = rootRef.current?.getBoundingClientRect() ?? null - if (r === null) return + const wrapper = rootRef.current + /* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */ + if (wrapper === null) return + const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) @@ -66,13 +68,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }, [open]) // The first placement ran before the card mounted (height read 0): once the - // card's real height is measurable, correct the bottom-edge clamp. + // card's real height is measurable, correct the bottom-edge clamp. The + // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null || typeof pos.top !== 'number') return + if (!open || pos === null) return + /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { - const top = window.innerHeight - h - 8 - if (pos.top !== top) setPos({ ...pos, top }) + setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } }, [open, pos]) diff --git a/packages/client/ui-primitives/tests/atoms.spec.tsx b/packages/client/ui-primitives/tests/atoms.spec.tsx index 440f4ba6ef..9e298d057a 100644 --- a/packages/client/ui-primitives/tests/atoms.spec.tsx +++ b/packages/client/ui-primitives/tests/atoms.spec.tsx @@ -136,6 +136,51 @@ describe('Menu', () => { expect(screen.getByRole('separator')).toBeDefined() }) + it('renders a non-interactive heading label and a danger row', () => { + const onSelect = vi.fn() + render( + <Menu + open + anchor={<span>trigger</span>} + items={[ + { type: 'label', id: 'h', text: 'Group by' }, + { id: 'del', label: 'Delete', danger: true }, + ]} + onSelect={onSelect} + onClose={() => {}} + />) + const heading = screen.getByText('Group by') + expect(heading.getAttribute('role')).toBe('presentation') + // The heading is not a menu item — only the danger row is interactive. + expect(screen.getAllByRole('menuitem')).toHaveLength(1) + const danger = screen.getByRole('menuitem', { name: 'Delete' }) + expect(danger.className).toMatch(/danger/) + fireEvent.click(danger) + expect(onSelect).toHaveBeenCalledWith('del') + }) + + it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => { + const onClose = vi.fn() + const { rerender } = render( + <Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + rerender( + <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />) + fireEvent.pointerLeave(screen.getByRole('menu')) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => { + const rowClick = vi.fn() + render( + <div onClick={rowClick}> + <Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} /> + </div>) + fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' })) + expect(rowClick).not.toHaveBeenCalled() + }) + it('opens a submenu on hover and selects a nested item', () => { const onSelect = vi.fn() render( diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx new file mode 100644 index 0000000000..c7a95f49fb --- /dev/null +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives' + +afterEach(cleanup) +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { vi.useRealTimers() }) + +/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ +function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { + const wrapper = anchor.parentElement as HTMLElement + wrapper.getBoundingClientRect = () => ({ + top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34, + width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}), + } as DOMRect) +} + +function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) { + const view = render( + <HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />, + ) + const anchor = screen.getByText('row') + stubAnchorRect(anchor, { top: 40, right: 200 }) + return { view, anchor, wrapper: anchor.parentElement as HTMLElement } +} + +describe('HoverCard', () => { + it('opens after the dwell delay, positioned right of the anchor', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(499) }) + expect(screen.queryByText('card body')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.parentElement).toBe(document.body) + expect(card.style.left).toBe('208px') + expect(card.style.top).toBe('40px') + }) + + it('honors a custom openDelayMs', () => { + const { wrapper } = mount({ openDelayMs: 50 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(50) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('pointerleave before the delay cancels the pending open', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + }) + + it('a press inside the anchor dismisses the card without waiting for disabled', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + fireEvent.pointerDown(screen.getByText('row')) + expect(screen.queryByText('card body')).toBeNull() + // The pending timer is also cleared: no reopen after the dwell. + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('disabled suppresses opening entirely', () => { + const { wrapper } = mount({ disabled: true }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('flipping disabled true closes an open card', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('card body')).toBeTruthy() + view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('corrects the bottom-edge clamp once the mounted card height is measurable', () => { + // First placement reads height 0 (card not yet mounted) and keeps the + // anchor top; the post-mount correction re-clamps with the real height. + window.innerHeight = 300 + const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')! + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 }) + try { + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + // 300 - 120 - 8 = 172, instead of the anchor top 280. + expect(card.style.top).toBe('172px') + } finally { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight) + } + }) + + it('clamps inside placement itself when the card is already measured (resize path)', () => { + window.innerHeight = 300 + const { wrapper } = mount() + stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { value: 120 }) + act(() => { fireEvent.resize(window) }) + expect(card.style.top).toBe('172px') + }) + + it('repositions on capture-phase scroll while open and stops listening after close', () => { + const { wrapper } = mount() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 }) + act(() => { fireEvent.scroll(document) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.left).toBe('308px') + expect(card.style.top).toBe('90px') + fireEvent.pointerLeave(wrapper) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('unmount clears a pending open timer', () => { + const { view, wrapper } = mount() + fireEvent.pointerEnter(wrapper) + view.unmount() + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('card body')).toBeNull() + }) +}) diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx index 57de2cbaf5..d051df2fdb 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx @@ -48,6 +48,7 @@ function GroupByMenu({ groupBy, onPick }: { items={GROUP_BY_ITEMS} selectedId={groupBy} onSelect={(id) => { + /* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */ if (id === 'workspace' || id === 'flat') onPick(id) setOpen(false) }} @@ -137,6 +138,7 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen onRename={group.workspaceId === undefined ? undefined : () => { + /* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */ if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label) }} /> @@ -154,9 +156,11 @@ function SessionTree({ useSessions, startSession, open, workspaces, query, onRen active: sameGroupDrag, marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null, hover: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */ setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } })) }, drop: (half: 'before' | 'after') => { + /* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */ if (drag === null) return const roots = group.sessions // Anchor = the row the insert line points at ('after' means @@ -218,6 +222,7 @@ function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessi currentId={list.current} now={now} onOpen={open} + /* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */ onToggle={() => {}} flat /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index fdc5c43b8e..b90fb9a601 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react' import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { RowDragProps } from '../src/client/rows/Rows.tsx' import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx' import type { GroupNode, SessionNode } from '../src/client/tree.ts' @@ -10,6 +11,32 @@ afterEach(cleanup) const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId +/** Half detection reads the row rect; jsdom rects are all-zero by default. */ +function stubRect(row: HTMLElement): void { + row.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, + x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) +} + +function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps { + return { + start: vi.fn(), active: false, marker: null, + hover: vi.fn(), drop: vi.fn(), end: vi.fn(), + ...overrides, + } +} + +const dataTransfer = { effectAllowed: '', dropEffect: '' } + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } }) + fireEvent(row, event) +} + describe('workspace browser rows', () => { it('renders an active Workspace and keeps its create action separate from toggling', () => { const onToggle = vi.fn() @@ -73,4 +100,152 @@ describe('workspace browser rows', () => { expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false') expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px') }) + + it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => { + const onRename = vi.fn() + const onToggle = vi.fn() + const group: GroupNode = { + key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + // Opening the menu neither toggles the group nor renames yet. + expect(onToggle).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + expect(onRename).toHaveBeenCalledOnce() + expect(screen.queryByRole('menu')).toBeNull() + // Delete stays visual-only: selecting it just closes the menu. + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onRename).toHaveBeenCalledOnce() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('ungrouped bucket renders no workspace menu', () => { + const group: GroupNode = { + key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped', + sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [], + } + render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />) + expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull() + }) + + it('session row menu opens without opening the session and closes on selection', () => { + const onOpen = vi.fn() + const node: SessionNode = { + id: sid('s1'), title: 'One', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />) + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + expect(onOpen).not.toHaveBeenCalled() + expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/) + fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(onOpen).not.toHaveBeenCalled() + // Escape closes without selecting (Menu onClose path). + fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('flat variant renders no twist even for a parent and ignores toggling', () => { + const node: SessionNode = { + id: sid('p'), title: 'Parent', children: [], hasChildren: true, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />) + expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull() + }) + + it('shows the hover card after the dwell and suppresses it while the row menu is open', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Hovered', children: [], hasChildren: false, + expanded: false, running: true, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />) + const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + // Card body: full title + relative time + running status. + expect(screen.getAllByText('Hovered')).toHaveLength(2) + expect(screen.getByText('1min ago')).toBeTruthy() + expect(screen.getByText('Running')).toBeTruthy() + fireEvent.pointerLeave(wrapper) + // Menu open (disabled=true) suppresses the card for the same hover. + fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' })) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(1000) }) + expect(screen.queryByText('1min ago')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('idle hover card shows the Idle status line', () => { + vi.useFakeTimers() + try { + const node: SessionNode = { + id: sid('s1'), title: 'Quiet', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />) + fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByText('Idle')).toBeTruthy() + expect(screen.getByText('now ago')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) + + it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => { + const node: SessionNode = { + id: sid('s1'), title: 'Drag me', children: [], hasChildren: false, + expanded: false, running: false, updatedAt: 0, + } + const inactive = dragProps() + const { rerender } = render( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />, + ) + const row = screen.getByRole('treeitem') + stubRect(row) + expect(row.getAttribute('draggable')).toBe('true') + fireEvent.dragStart(row, { dataTransfer }) + expect(inactive.start).toHaveBeenCalledOnce() + // Inactive drag: hover and drop are rejected. + fireEvent.dragOver(row, { dataTransfer }) + fireEvent.drop(row, { dataTransfer }) + expect(inactive.hover).not.toHaveBeenCalled() + expect(inactive.drop).not.toHaveBeenCalled() + fireEvent.dragEnd(row) + expect(inactive.end).toHaveBeenCalledOnce() + + const active = dragProps({ active: true, marker: 'before' }) + rerender( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />, + ) + stubRect(screen.getByRole('treeitem')) + // Top half hovers/drops 'before'; bottom half 'after' (row mid = 117). + fireDrag(screen.getByRole('treeitem'), 'dragOver', 105) + expect(active.hover).toHaveBeenCalledWith('before') + fireDrag(screen.getByRole('treeitem'), 'dragOver', 130) + expect(active.hover).toHaveBeenCalledWith('after') + fireDrag(screen.getByRole('treeitem'), 'drop', 130) + expect(active.drop).toHaveBeenCalledWith('after') + + const after = dragProps({ active: true, marker: 'after' }) + rerender( + <SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />, + ) + expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/) + }) }) diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index d114d43dea..b314e14571 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest' import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' -import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' const sid = (id: string) => id as SessionId const wid = (id: string) => id as WorkspaceId @@ -52,6 +53,12 @@ describe('deriveGroups', () => { expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false) }) + it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const } + const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view()) + expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false })) + }) + it('search filters real Sessions and omits the Intent placeholder', () => { const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const } const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match')) @@ -135,6 +142,39 @@ describe('deriveGroups', () => { }) }) +describe('deriveFlat', () => { + it('flattens every session — fork children included — newest-first with id tiebreak', () => { + const parent = summary('parent', 10) + const child = { ...summary('child', 30), parentId: parent.id } + const tieB = summary('tie-b', 20) + const tieA = summary('tie-a', 20) + const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' }) + expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')]) + // Rows are branch-free: no children, no expansion. + expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true) + }) + + it('search filters by case-insensitive display-title substring', () => { + const hit = { ...summary('hit', 2), displayTitle: 'Needle row' } + const miss = { ...summary('miss', 1), displayTitle: 'Other' } + expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')]) + }) + + it('tolerates ids whose summary has not landed yet', () => { + const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] } + expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')]) + }) +}) + +describe('createWorkspaceViewStore', () => { + it('defaults to workspace grouping; setGroupBy is the sole mutation', () => { + const store = createWorkspaceViewStore().create() + expect(store.getSnapshot().groupBy).toBe('workspace') + store.actions.setGroupBy('flat') + expect(store.getSnapshot().groupBy).toBe('flat') + }) +}) + describe('projectLabel', () => { it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => { expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL) diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx new file mode 100644 index 0000000000..3e592c3168 --- /dev/null +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -0,0 +1,458 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' +import type { + SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts' +import { createWorkspaceViewStore } from '../src/client/stores.ts' +import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx' + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +const sid = (id: string) => id as SessionId +const wid = (id: string) => id as WorkspaceId +const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({ + id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides, +}) +const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({ + ids: items.map(item => item.id), + byId: Object.fromEntries(items.map(item => [item.id, item])), + current: undefined, + phase: 'ready', + intent: undefined, + ...overrides, +}) +const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ + workspaceId: wid(id), path: `/projects/${id}`, title, + sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', +}) +const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ + items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true, + recentWorkspaceId: items[0]?.workspaceId, +}) +const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot) + +/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */ +function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void { + const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row) + Object.defineProperty(event, 'clientY', { value: clientY }) + Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } }) + fireEvent(row, event) +} + +function mount(overrides: Partial<WorkspaceBrowserProps> = {}) { + const store = createWorkspaceViewStore().create() + const props: WorkspaceBrowserProps = { + wide: true, + expandSidebar: vi.fn(), + useSessions: hook(sessionState([])), + useWorkspaces: hook(workspaceState([])), + useStore: bindSnapshotSelector(store), + actions: store.actions, + startSession: vi.fn(), + open: vi.fn(), + renameWorkspace: vi.fn(async () => {}), + insertSessionBefore: vi.fn(async () => {}), + createWorkspace: vi.fn(async () => workspace('created', [])), + ...overrides, + } + const view = render(<WorkspaceBrowser {...props} />) + return { view, props, store } +} + +/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */ +function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) { + Object.assign(b.props, overrides) + b.view.rerender(<WorkspaceBrowser {...b.props} />) +} + +describe('WorkspaceBrowser', () => { + it('renders the grouped tree by default and switches to the flat list via Group by', () => { + const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])), + }) + expect(screen.getByText('Workspaces')).toBeTruthy() + expect(screen.getByText('alpha')).toBeTruthy() + // Sessions hidden while their group is folded. + expect(screen.queryByText('alpha-s')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label + fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' })) + // Store-driven flip: title changes, rows flatten newest-first, headers gone. + expect(b.store.getSnapshot().groupBy).toBe('flat') + expect(screen.getByText('Sessions')).toBeTruthy() + expect(screen.queryByText('alpha')).toBeNull() + expect(screen.getByText('alpha-s')).toBeTruthy() + expect(screen.getByText('beta-s')).toBeTruthy() + + // Back to workspace grouping through the same menu. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' })) + expect(b.store.getSnapshot().groupBy).toBe('workspace') + expect(screen.getByText('Workspaces')).toBeTruthy() + + // Escape closes the menu without picking. + fireEvent.click(screen.getByRole('button', { name: 'Group by' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + expect(b.store.getSnapshot().groupBy).toBe('workspace') + }) + + it('expands a group on click and opens a session row', () => { + const open = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + open, + }) + fireEvent.click(screen.getByText('alpha')) + fireEvent.click(screen.getByText('alpha-s')) + expect(open).toHaveBeenCalledWith(sid('alpha-s')) + // Collapse hides the row again. + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('alpha-s')).toBeNull() + }) + + it('unfolds a session subtree through the row twist', () => { + const parent = summary('parent-s', 2) + const child = { ...summary('child-s', 1), parentId: parent.id } + mount({ + useSessions: hook(sessionState([parent, child])), + useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])), + }) + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('child-s')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Expand' })) + expect(screen.getByText('child-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })) + expect(screen.queryByText('child-s')).toBeNull() + }) + + it('auto-expands the selected session group and starts a session from the group +', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })), + useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])), + startSession, + }) + // The current-group effect expanded the owning group without a click. + expect(screen.getByText('alpha-s')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' })) + expect(startSession).toHaveBeenCalledWith(wid('alpha')) + }) + + it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its + is inert', () => { + const startSession = vi.fn() + mount({ + useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + startSession, + }) + // The loose session's group is UNGROUPED_KEY: expanded by the effect. + expect(screen.getByText('loose')).toBeTruthy() + expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' })) + expect(startSession).not.toHaveBeenCalled() + }) + + it('keeps an already-expanded group when the selection moves within it', () => { + const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') }) + const b = mount({ + useSessions: hook(first), + useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])), + }) + expect(screen.getByText('a')).toBeTruthy() + // Selection hop inside the same group: the effect re-runs and leaves the + // expansion list unchanged (no duplicate key, group still open). + rerender(b, { useSessions: hook({ ...first, current: sid('b') }) }) + expect(screen.getByText('b')).toBeTruthy() + fireEvent.click(screen.getByText('alpha')) + expect(screen.queryByText('b')).toBeNull() + }) + + it('renders the intent placeholder in both modes', () => { + const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const } + const sessions = sessionState([], { intent, current: sid('intent') }) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', [])])), + }) + // Grouped: the current-group effect expands the target group. + expect(screen.getByText('New session')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('New session')).toBeTruthy() + }) + + it('searches across groups, clears via the clear button, and shows the empty states', () => { + const sessions = sessionState([ + summary('needle-row', 2, { displayTitle: 'Needle row' }), + summary('other-row', 1, { displayTitle: 'Other row' }), + ]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])), + }) + const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...') + fireEvent.change(input, { target: { value: 'needle' } }) + // Search forces matches visible without expansion state. + expect(screen.getByText('Needle row')).toBeTruthy() + expect(screen.queryByText('Other row')).toBeNull() + fireEvent.change(input, { target: { value: 'zzz' } }) + expect(screen.getByText('No matches')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })) + expect(input.value).toBe('') + // Clicking the field row focuses the input (wide mode). + fireEvent.click(input.parentElement as HTMLElement) + expect(document.activeElement).toBe(input) + }) + + it('shows the no-sessions empty state in both modes', () => { + const b = mount() + expect(screen.getByText('No sessions yet')).toBeTruthy() + b.store.actions.setGroupBy('flat') + rerender(b, {}) + expect(screen.getByText('No sessions yet')).toBeTruthy() + // Flat search misses show No matches. + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } }) + expect(screen.getByText('No matches')).toBeTruthy() + }) + + it('rail state renders icon controls that request expansion', () => { + vi.useFakeTimers() + try { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar }) + // No wide chrome in rail state. + expect(screen.queryByText('Workspaces')).toBeNull() + expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + // The wide flip mounts the input and focuses it after the slide. + rerender(b, { wide: true }) + const input = screen.getByPlaceholderText('Search name, keywords...') + act(() => { vi.advanceTimersByTime(300) }) + expect(document.activeElement).toBe(input) + // Wide search button is decorative (tabIndex -1, no expand call). + fireEvent.click(screen.getByRole('button', { name: 'Search sessions' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => { + const expandSidebar = vi.fn() + const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) }) + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(expandSidebar).toHaveBeenCalledTimes(1) + rerender(b, { wide: true }) + // The picker menu is open (anchored on the +); picking starts a session. + fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' })) + expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha')) + expect(screen.queryByRole('menu')).toBeNull() + // Wide toggle: open and close without expand requests. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.getByRole('menu')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + expect(screen.queryByRole('menu')).toBeNull() + expect(expandSidebar).toHaveBeenCalledTimes(1) + + // Escape closes the picker through its own onClose. + fireEvent.click(screen.getByRole('button', { name: 'Create workspace' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menu')).toBeNull() + }) + + it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header + const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement] + three.getBoundingClientRect = () => ({ + top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + // Drop on the top half of "three": insert one before three. + fireDrag(three, 'dragOver', 205) + fireDrag(three, 'drop', 205) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three')) + + // Dropping right back onto its own position is a no-op — top half + // (anchor = itself) and bottom half (anchor = the next root) alike. + fireEvent.dragStart(one, { dataTransfer }) + one.getBoundingClientRect = () => ({ + top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}), + } as DOMRect) + fireDrag(one, 'dragOver', 105) + fireDrag(one, 'drop', 105) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(one, 'drop', 130) + expect(insertSessionBefore).toHaveBeenCalledTimes(1) + }) + + it('still sends the reorder when the dragged row left the group mid-drag', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + const b = mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement + fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } }) + // The host dropped "one" from the workspace account while the drag is in + // flight: the source index is gone but the drop still resolves its anchor. + rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) }) + const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + fireDrag(two, 'drop', 155) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two')) + }) + + it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => { + const insertSessionBefore = vi.fn(async () => {}) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireEvent.dragEnd(one) + // The drag ended: rows no longer accept drops. + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).not.toHaveBeenCalled() + + // Bottom half of the last row: append (anchor omitted). + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'dragOver', 180) + fireDrag(two, 'drop', 180) + expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined) + }) + + it('logs and keeps the order when the reorder call rejects', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') }) + const sessions = sessionState([summary('one', 2), summary('two', 1)]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])), + insertSessionBefore, + }) + fireEvent.click(screen.getByText('alpha')) + const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement] + two.getBoundingClientRect = () => ({ + top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}), + } as DOMRect) + const dataTransfer = { effectAllowed: '', dropEffect: '' } + fireEvent.dragStart(one, { dataTransfer }) + fireDrag(two, 'drop', 180) + await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) }) + } finally { + warn.mockRestore() + } + }) + + it('renames a workspace through the row menu dialog', async () => { + let resolveRename!: () => void + const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve })) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText<HTMLInputElement>('Workspace name') + expect(input.value).toBe('Alpha') + // Unchanged and blank names stay blocked. + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: ' ' } }) + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + // A duplicate of another workspace's title shows the inline conflict. + fireEvent.change(input, { target: { value: ' Beta ' } }) + expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.') + expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true) + fireEvent.change(input, { target: { value: 'Gamma' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma') + // While renaming: input disabled, close blocked, Enter ignored. + expect(input.disabled).toBe(true) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.getByRole('dialog')).toBeTruthy() + await act(async () => { resolveRename() }) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('rename via Enter, failure surfaces the error, Cancel closes', async () => { + const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + const input = screen.getByLabelText<HTMLInputElement>('Workspace name') + // Enter with a blocked draft (unchanged) does nothing. + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).not.toHaveBeenCalled() + fireEvent.change(input, { target: { value: 'Renamed' } }) + fireEvent.keyDown(input, { key: 'a' }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed') + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') }) + // The dialog stays for retry; typing clears the error; Cancel closes. + fireEvent.change(input, { target: { value: 'Renamed2' } }) + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })) + expect(screen.queryByRole('dialog')).toBeNull() + }) + + it('reports non-Error rename failures as text', async () => { + const renameWorkspace = vi.fn(async () => { throw 'denied' }) + mount({ + useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])), + renameWorkspace, + }) + fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' })) + fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } }) + fireEvent.click(screen.getByRole('button', { name: 'Rename' })) + await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) + }) + + it('search hides drag affordances (rows are not draggable during search)', () => { + const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })]) + mount({ + useSessions: hook(sessions), + useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])), + }) + fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } }) + const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement + expect(row.getAttribute('draggable')).toBe('false') + }) +}) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 25b095b99f..a81aadd94b 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -68,6 +68,19 @@ describe('unary round trip', () => { expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } }) }) + it('routes workspace rename and insertSessionBefore through the wire', async () => { + const api = scriptedApi() + const c = client(api) + const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' }) + expect(renamed.result.ok).toBe(true) + const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' }) + expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } }) + const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') }) + expect(anchored.result.ok).toBe(true) + const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') }) + expect(appended.result.ok).toBe(true) + }) + it('passes business errors through as 200 + err result, not a throw', async () => { const api = scriptedApi({ sessions: { diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index d0f2ddd128..ebb7931e6e 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { RpcId } from '../src/api/rpc.ts' +import { RpcId, transportError } from '../src/api/rpc.ts' import { clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema, rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema, @@ -13,8 +13,10 @@ import { } from '../src/api/sessions.schema.ts' import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts' import { - workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema, - workspaceListValueSchema, workspaceViewSchema, + workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, + workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema, + workspaceListRequestSchema, workspaceListValueSchema, + workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema, } from '../src/api/workspace.schema.ts' import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts' import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts' @@ -30,6 +32,13 @@ describe('RpcId', () => { }) }) +describe('transportError', () => { + it('folds Error and non-Error throws into the internal error branch', () => { + expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } }) + expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } }) + }) +}) + describe('rpcErrorSchema', () => { it('accepts every code branch with its required details', () => { expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request') @@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => { expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found') expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path') expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict') + expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid') expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy') expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal') }) @@ -154,6 +164,19 @@ describe('workspace domain schemas', () => { expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) + it('rename requires a non-blank title (both refine arms)', () => { + expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new') + expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/) + expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + + it('insertSessionBefore accepts an anchored and an anchorless move', () => { + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2') + expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined() + expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow() + expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1') + }) + }) describe('events frame schemas', () => { diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index bdefc2ec82..8ce70cc7d5 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -10,7 +10,7 @@ import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionHeader } from '@deepseek-ai/dsh-session' import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' -import WorkspaceRegistry, { WorkspaceId, WorkspaceNameConflictError } from '../src/index.ts' +import WorkspaceRegistry, { WorkspaceId, WorkspaceMoveInvalidError, WorkspaceNameConflictError } from '../src/index.ts' import type { WorkspaceDomainState, WorkspaceRecord } from '../src/index.ts' const DOMAIN_VERSION = 2 @@ -449,6 +449,56 @@ describe('Workspace session ordering', () => { expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s2', 's1']) }) + it('moves one id before an anchor or to the end, durably', async () => { + const dir = await makeDir('insert-before') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2), header('s3', dir, 3)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + await workspace.attachSession(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s3', 's2', 's1']) + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s2')) + expect(workspace.sessionIds).toEqual(['s3', 's1', 's2']) + await workspace.insertSessionBefore(SessionId('s3')) + expect(workspace.sessionIds).toEqual(['s1', 's2', 's3']) + expect(storedRecord(result.pool, workspace.id).sessionIds).toEqual(['s1', 's2', 's3']) + }) + + it('treats self-anchored and already-in-place moves as no-ops without writing', async () => { + const dir = await makeDir('insert-noop') + const result = await harness() + result.setSessions([header('s1', dir, 1), header('s2', dir, 2)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + await workspace.attachSession(SessionId('s2')) + const written = result.changes.length + + await workspace.insertSessionBefore(SessionId('s1'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s2'), SessionId('s1')) + await workspace.insertSessionBefore(SessionId('s1')) + await workspace.detachSession(SessionId('absent')) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s2', 's1']) + }) + + it('rejects moves naming an unaccounted session or anchor', async () => { + const dir = await makeDir('insert-invalid') + const result = await harness() + result.setSessions([header('s1', dir, 1)]) + const workspace = await result.registry.create(dir) + await workspace.attachSession(SessionId('s1')) + const written = result.changes.length + + await expect(workspace.insertSessionBefore(SessionId('ghost'))) + .rejects.toBeInstanceOf(WorkspaceMoveInvalidError) + await expect(workspace.insertSessionBefore(SessionId('s1'), SessionId('ghost'))) + .rejects.toThrow(/anchor session is not accounted/) + expect(result.changes).toHaveLength(written) + expect(workspace.sessionIds).toEqual(['s1']) + }) + it('validates a lazy live session without requiring it in persistence.list()', async () => { const dir = await makeDir('live') const result = await harness({ sessions: [], liveSessions: [header('live', dir, 1)] }) From d147673dfdcdf393ccf6a62ed0549a291831b18d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:58:53 +0800 Subject: [PATCH 61/70] refactor(apiproxy): share the workspace-not-found response The rename/insertSessionBefore lookups tripped the cross-file clone gate; one helper owns the error row now. --- packages/host/apiproxy/src/api-proxy.ts | 25 +++++++++++-------------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index d50dd000e1..c7d3cb7ef9 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -299,6 +299,15 @@ class SessionCwdConflict extends Error { /** Host failed before the registry could adopt a name-created directory. */ class WorkspaceDirectoryCreationError extends Error {} +/** Shared workspace-not-found error response of the workspace.* mutation rows. */ +function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> { + return err(request, { + code: 'workspace-not-found', + message: `workspace "${workspaceId}" not found`, + details: { workspaceId }, + }) +} + /** Wire projection of one workspace entity (the workspace.* value row). */ function workspaceView(workspace: Workspace): WorkspaceView { return { @@ -684,13 +693,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async rename(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) const title = payload.title.trim() // Uniqueness AND the same-title no-op both ride the create chain so // they observe the state left by earlier queued renames — checked @@ -722,13 +725,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async insertSessionBefore(request) { const { payload } = request const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId)) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `workspace "${payload.workspaceId}" not found`, - details: { workspaceId: payload.workspaceId }, - }) - } + if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId) try { await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId) } catch (error: unknown) { From 4ae86001a5cdd6f6a03a34909a49f417151d9836 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:03:05 +0800 Subject: [PATCH 62/70] style(web): wrap a long tree.ts doc line --- packages/client/ui-workspace/src/client/tree.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts index 39a479f3b3..7de4d14e8d 100644 --- a/packages/client/ui-workspace/src/client/tree.ts +++ b/packages/client/ui-workspace/src/client/tree.ts @@ -227,7 +227,8 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] { * * Normal mode: every group shows; sessions populate under expanded groups, * descending only into expanded sessions. A frontend Session Intent targeting - * a real Workspace marks that group `intentHere` (rendered only while the group is expanded; expansion stays viewer-owned). Search mode (non-blank query, + * a real Workspace marks that group `intentHere` (rendered only while the + * group is expanded; expansion stays viewer-owned). Search mode (non-blank query, * case-insensitive display-title substring): expansion state is ignored — * matched sessions and their ancestor chains are forced visible, groups * without a display-title or label hit are dropped, a label-only hit keeps From 2cfc38fb70c9128c3a80c5a5dd753769cf7ef4fd Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:10:24 +0800 Subject: [PATCH 63/70] docs(agent-notes): clarify superseded ACP rendering --- .../2026-06-20-drop-acp-terminal-meta.i18n.yaml | 4 ++-- .../2026-06-20-drop-acp-terminal-meta.md | 8 ++++---- .../2026-06-20-drop-acp-terminal-meta.zh.md | 8 ++++---- .../2026-06-20-generic-tool-rendering.i18n.yaml | 4 ++-- .../2026-06-20-generic-tool-rendering.md | 12 +++++++----- .../2026-06-20-generic-tool-rendering.zh.md | 12 +++++++----- 6 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml index 8b3e3f7391..b7b5632e38 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.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-06-20-drop-acp-terminal-meta.md: d957ba1173af28cb526c92f959a8552f77360a57 -2026-06-20-drop-acp-terminal-meta.zh.md: 3a748c8fdf2ef37d35a14519fee5284af417dd78 +2026-06-20-drop-acp-terminal-meta.md: 84b9028392f967e72f7b5585d013d669735631de +2026-06-20-drop-acp-terminal-meta.zh.md: 6ac7ba46bce20bcf2593ef422057e0562f8a1557 diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md index d957ba1173..84b9028392 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.md @@ -1,14 +1,14 @@ # Agent Note: Drop ACP terminal `_meta` rendering -Status: rejected — Zed is the current target client, and the terminal `_meta` convention is intentional Zed UX with a plain ACP fallback for other clients. +Status: rejected — removing only Zed terminal metadata was rejected while ACP remained an editor bridge; automation-only ACP later removed the whole editor projection. English | [中文](2026-06-20-drop-acp-terminal-meta.zh.md) ## Problem -The former ACP editor bridge implemented a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The current [render-intent decision](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) preserves the underlying rule that bash execution belongs in the harness and terminal cards are display-only. The later [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes the `_meta` projection, bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing from ACP. +The former ACP editor bridge implemented a Zed-specific terminal-card convention through `_meta.terminal_info`, `_meta.terminal_output`, and `_meta.terminal_exit`. The current [render-intent decision](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) preserves the underlying rule that bash execution belongs in the harness and terminal cards are display-only. The later [automation-only ACP decision](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes the `_meta` projection, bridge state, capability negotiation, terminal ids, special update mapping, text fallback tests, and exit-pill parsing from ACP. TUI and the Web host/client runtime retain the tagged presentation contract, while ACP no longer renders editor cards. -The fallback path already exists: render the tool call and completed output as normal ACP content blocks. Non-Zed clients rely on that path anyway, but the Zed terminal card is a current target-client feature rather than speculative decoration. +At proposal time, the fallback path already existed: render the tool call and completed output as normal ACP content blocks. Non-Zed clients relied on that path, but the Zed terminal card was a target-client feature rather than speculative decoration. ## Proposal @@ -26,6 +26,6 @@ This proposal is narrower than [collapsing tool-owned UI presentation](2026-06-2 ## What we give up -Zed users lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They still see the command and output as plain content. That is a reasonable simplification while the ACP bridge is still unreleased and the `_meta` keys are a convention rather than a standard. +Under this proposal, Zed users would lose the dedicated terminal card: no cwd header, terminal display, or exit pill. They would still see the command and output as plain content. That was a reasonable simplification to consider while the ACP bridge was unreleased and the `_meta` keys were a convention rather than a standard. <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md index 3a748c8fdf..6ac7ba46bc 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-drop-acp-terminal-meta.zh.md @@ -1,14 +1,14 @@ # Agent Note: 移除 ACP(Agent Client Protocol)终端 `_meta` 渲染 -Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是有意设计的 Zed UX,同时为其他客户端保留普通 ACP 回退。 +Status: rejected — 在 ACP 仍是编辑器桥接层时,仅移除 Zed 终端元数据的方案被否决;后续仅面向自动化的 ACP 则移除了整个编辑器投影。 [English](2026-06-20-drop-acp-terminal-meta.md) | 中文 ## 问题 -原 ACP 编辑器桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。当前的 [render-intent 决策](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)保留了底层规则:bash 执行属于 harness,terminal 卡片只用于展示。后续的[仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)从 ACP 中移除了 `_meta` 投影、桥接状态、能力协商、终端 id、特殊 update 映射、文本回退测试和 exit-pill 解析。 +原 ACP 编辑器桥接层通过 `_meta.terminal_info`、`_meta.terminal_output` 和 `_meta.terminal_exit` 实现了一套 Zed 特有的终端卡片约定。当前的 [render-intent 决策](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)保留了底层规则:bash 执行属于 harness,terminal 卡片只用于展示。后续的[仅面向自动化 ACP 决策](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)从 ACP 中移除了 `_meta` 投影、桥接状态、能力协商、终端 id、特殊 update 映射、文本回退测试和 exit-pill 解析。TUI 与 Web 宿主/客户端运行时保留带标签的展示契约,而 ACP 不再渲染编辑器卡片。 -回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。非 Zed 客户端本来就依赖这条路径,但 Zed 终端卡片是当前目标客户端的功能特性,而非推测性装饰。 +本提案提出时,回退路径已经存在:将工具调用和完成输出渲染为普通 ACP 内容块。当时,非 Zed 客户端依赖这条路径,但 Zed 终端卡片是目标客户端的功能特性,而非推测性装饰。 ## 提案 @@ -26,6 +26,6 @@ Status: rejected — Zed 是当前目标客户端,terminal `_meta` 约定是 ## 放弃的内容 -Zed 用户将失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。他们仍能以纯内容形式看到命令和输出。在 ACP 桥接层尚未发布、`_meta` 键只是约定而非标准的阶段,这是合理的简化。 +如果采用本提案,Zed 用户会失去专用终端卡片:没有 cwd 头部、终端展示或 exit pill。但他们仍会以纯内容形式看到命令和输出。当时 ACP 桥接层尚未发布,且 `_meta` 键只是约定而非标准;在这种情况下,考虑这项简化是合理的。 <!-- agent-note-format: alternatives-not-recorded (pre-format Agent Note) --> diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml index da93063670..14c7033185 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.i18n.yaml +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.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-06-20-generic-tool-rendering.md: 6fc610546da04e7d1e16fc17ada87483a142aa3c -2026-06-20-generic-tool-rendering.zh.md: 11386b87d845129950a8473eb1cf4ea6ce697ac8 +2026-06-20-generic-tool-rendering.md: a9ceb7a0e016b57295e3226e98a7fce51e49c21f +2026-06-20-generic-tool-rendering.zh.md: 553c1caa23ed34eea5f113372d12a7381dc2488a diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md index 6fc610546d..a9ceb7a0e0 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.md @@ -1,14 +1,16 @@ # Agent Note: Collapse tool-owned UI presentation -Status: rejected — tool-owned presentation should wait for more real tools before being generalized or deleted. Bash and ACP currently need the existing richer presentation path. +Status: rejected — TUI and the Web host/client runtime consume the tagged render-intent union, so tool-owned presentation remains current even though ACP no longer projects it. English | [中文](2026-06-20-generic-tool-rendering.zh.md) ## Problem -Tools can define `presentCall()` and `presentResult()` callbacks that return `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flags the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal grew incrementally into a bag of optional fields. ACP then maintains pending call state to pair a result with the original args, creates replay-only presenters on `session/load`, and maps terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parses exit status back out of rendered text because the pure replay-safe presenter no longer has the structured `BashRunResult`. +The optional-field bag and ACP editor mapping below were the proposal-time context for this rejection. The current contracts live in [the tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) and [automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md). -The real first-party use is bash presentation for ACP. That is too little evidence to freeze a cross-package UI presentation API. +Tools could define `presentCall()` and `presentResult()` callbacks that returned `ToolCallPresentation`, `ToolResultPresentation`, and optional `ToolTerminal` fields. The code itself flagged the design as muddy: title, kind, raw input, content, terminal cwd, terminal output, exit code, and signal had grown incrementally into a bag of optional fields. ACP then maintained pending call state to pair a result with the original args, created replay-only presenters on `session/load`, and mapped terminal subfields into Zed-specific `_meta`. `dsh-tool-bash` even parsed exit status back out of rendered text because the pure replay-safe presenter no longer had the structured `BashRunResult`. + +The real first-party use was bash presentation for ACP. That was too little evidence to freeze a cross-package UI presentation API. ## Proposal @@ -28,8 +30,8 @@ As a smaller alternative, replace the current optional-field bag with one explic ## What we give up -Bash loses its custom terminal-looking card and model-written description placement. The fallback remains reasonable: the command appears as tool input, and the output appears as text. Rich rendering should be designed when the product has enough UI/tool variety to justify a stable presentation contract. +Under this proposal, Bash would lose its custom terminal-looking card and model-written description placement. The fallback would remain reasonable: the command would appear as tool input, and the output as text. Rich rendering would be designed when the product had enough UI/tool variety to justify a stable presentation contract. ## Related -This is the broad version of [dropping ACP terminal metadata](2026-06-20-drop-acp-terminal-meta.md). If this Agent Note is accepted, that narrower Agent Note becomes unnecessary. +The later [tagged render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md) implements the smaller alternative once multiple producer and consumer families provide enough evidence for the vocabulary. [Automation-only ACP](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md) removes ACP's editor projection without removing tool-owned presentation from TUI or the Web host/client runtime. diff --git a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md index 11386b87d8..553c1caa23 100644 --- a/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md +++ b/.agents/notes/rejected/simplification/2026-06-20-generic-tool-rendering.zh.md @@ -1,14 +1,16 @@ # Agent Note: 收拢工具自有的 UI 展示逻辑 -Status: rejected — 工具拥有的呈现机制应等到出现更多真实工具后再进行泛化或删除。Bash 与 ACP(Agent Client Protocol)目前仍需要现有的丰富呈现路径。 +Status: rejected — 尽管 ACP(Agent Client Protocol)已不再投影这套契约,TUI 与 Web 宿主/客户端运行时仍消费带标签 render-intent 联合类型,因此工具自有的展示仍然有效。 [English](2026-06-20-generic-tool-rendering.md) | 中文 ## 问题 -工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 +下文所述的可选字段集合与 ACP 的编辑器映射,是本提案遭否决时的背景。当前契约分别由[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)与[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)承载。 -真正的第一方用途是为 ACP 提供 bash 展示。这不足以作为冻结一个跨包(package)UI 展示 API 的依据。 +当时,工具可以定义 `presentCall()` 和 `presentResult()` 回调,返回 `ToolCallPresentation`、`ToolResultPresentation` 以及可选的 `ToolTerminal` 字段。代码本身就标记了这个设计的混乱:title、kind、raw input、content、terminal cwd、terminal output、exit code 和 signal 已经逐步增长为一堆可选字段。ACP 随后维护 pending call 状态以将 result 与原始 args 配对,在 `session/load` 时创建仅用于回放的 presenter,并将 terminal 子字段映射为 Zed 特有的 `_meta`。`dsh-tool-bash` 甚至从渲染后的文本中反向解析退出状态,因为纯回放安全的 presenter 已经拿不到结构化的 `BashRunResult`。 + +当时,真正的第一方用途是为 ACP 提供 bash 展示。这点证据不足以作为冻结一个跨包(package)UI 展示 API 的依据。 ## 提案 @@ -28,8 +30,8 @@ Status: rejected — 工具拥有的呈现机制应等到出现更多真实工 ## 放弃了什么 -Bash 失去其自定义的终端风格卡片和模型生成描述的放置位置。回退方案仍然合理:命令作为工具输入展示,输出作为文本展示。富展示应当在产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时再行设计。 +如果采用本提案,Bash 会失去其自定义的终端风格卡片和模型生成描述的放置位置。届时,回退方案仍然合理:命令会作为工具输入展示,输出会作为文本展示。只有当产品拥有足够的 UI/工具多样性、足以支撑一份稳定的展示契约时,才会设计富展示。 ## 相关 -这是[移除 ACP terminal 元数据](2026-06-20-drop-acp-terminal-meta.md)的宽泛版本。如果本 Agent Note(agent 决策记录)被接受,那个更窄的 Agent Note 就不再必要。 +后续的[带标签 render-intent 联合类型](../../implemented/architecture/2026-07-02-tool-render-intent-union.md)在多类生产者与消费方为这套词汇提供充分依据后,实现了较小的替代方案。[ACP 作为仅面向自动化的协议](../../implemented/simplification/2026-07-23-acp-automation-only-protocol.md)移除了 ACP 的编辑器投影,但没有从 TUI 或 Web 宿主/客户端运行时中移除工具自有的展示。 From ad419065a0a71a49f801028d66cd0afbdd56df36 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:10:59 +0800 Subject: [PATCH 64/70] test(web): stop pre-clicking the fixture group in the title snapshot The Intent's current-group effect already expands the target workspace; with intent no longer forcing expansion, the header click collapsed it. --- apps/web/tests/session-title.snapshot.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/session-title.snapshot.ts b/apps/web/tests/session-title.snapshot.ts index c1616bb724..532c6866da 100644 --- a/apps/web/tests/session-title.snapshot.ts +++ b/apps/web/tests/session-title.snapshot.ts @@ -94,10 +94,10 @@ it('projects initial and revised durable titles through the built nine-plugin fi }) const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) - const projectCount = await within(tree).findByText('4 sessions') - const projectRow = projectCount.closest<HTMLElement>('[role="treeitem"]') - if (projectRow === null) throw new Error('fixture project row missing') - fireEvent.click(projectRow) + // The fixture Intent selects the workspace, so the current-group effect + // already expanded it; clicking the header would now collapse (the twist + // stays live since intent stopped forcing expansion). + await within(tree).findByText('4 sessions') const initialLabel = 'Fixture 历史会话' const initialRowLabel = await screen.findByText(initialLabel) From b2d8815a34b584a8704d2285e33ea45b279fc337 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:29:13 +0800 Subject: [PATCH 65/70] test(llm-mock-server): poll for the client-closed outcome The fixed 5ms sleep raced slow CI runners (the server observes the socket close asynchronously); vi.waitFor polls until the outcome lands. --- packages/support/llm-mock-server/tests/server.spec.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/support/llm-mock-server/tests/server.spec.ts b/packages/support/llm-mock-server/tests/server.spec.ts index 1f15ba1a0b..34db57aa82 100644 --- a/packages/support/llm-mock-server/tests/server.spec.ts +++ b/packages/support/llm-mock-server/tests/server.spec.ts @@ -1,5 +1,5 @@ import { request } from 'node:http' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import type { MockLlmBehavior, MockLlmServer, MockLlmServerEvent } from '../src/index.ts' import { startMockLlmServer } from '../src/index.ts' @@ -179,9 +179,11 @@ describe('mock LLM server wire behaviors', () => { const response = await chat(server, { signal: controller.signal }) controller.abort() await expect(response.text()).rejects.toThrow() - await new Promise((resolve) => { setTimeout(resolve, 5) }) - - expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + // The server observes the socket close asynchronously; a fixed sleep + // raced slow runners, so poll until the outcome lands. + await vi.waitFor(() => { + expect(server.requests[0]).toMatchObject({ behavior, outcome: 'client_closed' }) + }) expect(events.filter(event => event.type === 'result')).toEqual([ expect.objectContaining({ behavior, outcome: 'client_closed' }), ]) From 7953759aa2cda3062d6a2211e35948976ca80a22 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:33:58 +0800 Subject: [PATCH 66/70] docs: remove missions folder --- missions/readme.md | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100644 missions/readme.md diff --git a/missions/readme.md b/missions/readme.md deleted file mode 100644 index 66167719ad..0000000000 --- a/missions/readme.md +++ /dev/null @@ -1,36 +0,0 @@ -# Workspace GUI 收尾备忘 - -## 产品改动 - -- 用户要求“去掉功能”时,先拆开视觉入口、可访问性语义和响应行为分别确认。本次 composer 加号保留原样和 `Add attachment` 标签,只在组合层停止传入 Workspace 回调;不要删除按钮、改样式或把它禁用。 -- 临时交互不应上浮到 React 呈现层。Session/Workspace Intent、首次消息保留和 materialize 重试归 runtime 对象与 service;组件只接收标准 action、hooks 和纯呈现状态。 -- RFC、测试名称和 PR 描述只写最终产品语义,不保留 `reconcilePublishedDraft`、`pendingCwd` 等已经撤销的中间方案。 - -## Snapshot 与测试定位 - -- `apps/web/tests/**/*.snapshot.ts` 验证 built application,需用 `DSH_EXAMPLE_MODE=lib`,并确认相关 `lib/` 已由当前源码构建;普通 source-mode Vitest 通过不能替代它。 -- 对 runtime 管理的受控输入执行 `fireEvent.change` 后,必须 `waitFor` 输入值回显再点击发送,否则发送可能读取旧的空 prompt。 -- 页面中 Workspace 与 Session 可以同名,禁止用无作用域的 `findByText` 定位。先用 `within` 锁定 Sessions tree、计数或对应 group,再找目标行。 -- 新 push 后先看 assembled snapshot 是否真正跑过;本地 focused snapshot 通过后仍以 `gh pr checks` 的 artifact job 为准。 - -## Coverage 收口 - -- 测试筛选和 coverage 筛选是两件事。用 owning tests 配合逐个 `--coverage.include='<source-file>'`,先拿到真实未覆盖行和分支,不要直接反复跑全仓 coverage。 -- 多个 coverage 进程并发时必须给不同的 `--coverage.reportsDirectory`,否则报告目录互相覆盖。各 worker 完成后再跑一次合并后的精确 coverage,确认共享 worktree 的改动组合起来仍为 100%。 -- 全仓 coverage 若先被无关测试超时打断,不能把它当作目标文件的结论;先用精确 include 修本分支缺口,再让 CI exhaustive coverage 验证整体。 -- Coverage 测试仍要描述行为,不写“为了覆盖某分支”的注释。不可达分支才使用已有规范允许的 `v8 ignore`,可达分支补真实行为测试。 - -## 并发与提交 - -- Coverage 适合按不相交写区并发:例如 Sidebar tests、Workspace picker tests、connection/storage tests。派工时明确“只改 tests、不改 src、不 commit、不得回滚他人改动”。 -- 不直接信任各 worker 的单独结果;主会话审查 diff、运行合并后的 focused coverage、清理生成报告,再统一 commit。 -- 推送前按 `dsh-pre-push-checks` 选择最小充分验证,不重复已经通过的检查;正常 push 让 pre-push typecheck 运行,并核对本地 HEAD 与远端 ref 一致。 -- 生成的 `.coverage/` 只属于本地诊断。环境拒绝 `rm -rf` 时,依次使用 `find .coverage -type f -delete` 和 `find .coverage -depth -type d -empty -delete`;不要让报告进入 commit。 - -## GitHub 与 CI - -- GitHub 操作统一走 `gh`,并从 git 配置注入代理:`proxy="$(git config --get http.https://github.com.proxy)"; https_proxy="$proxy" http_proxy="$proxy" GH_PAGER=cat ~/.local/bin/gh ...`。不要改用网页。 -- 每次 push 都会产生一轮新 checks;旧轮次的失败不能代表当前 HEAD。先确认 run 对应当前提交,再拉失败日志。 -- `gh run watch` 只监视一个 workflow。最终必须用 `gh pr checks` 汇总 CI、e2e、sandbox 和 Windows 等独立 workflow;偶发平台失败先等当前 HEAD 重跑结果,不预先修改无关代码。 -- PR base 和 description 在最终 push 后再次用 `gh pr edit --base ... --body-file ...` 同步。PR 描述应包含最终产品动线、架构边界和实际运行过的验证,不写仍待执行的承诺。 -- Review thread 用 GraphQL/`gh api` 检查 `isResolved` 和已有回复,避免对已经解决的旧实现评论重复修复。 From 5fad2fc93445ae2875048b7009ab2deef6161f9d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:45:56 +0800 Subject: [PATCH 67/70] docs: define extensible PR labels --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index eae3007c86..e68ef8086a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- Pick matching existing GitHub labels such as `documentation`, `web`, `tui`, or `core`; never create new labels. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 6ce5628bb140a40f5540eac2cbcc821ed8822cf5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:51:48 +0800 Subject: [PATCH 68/70] docs: tighten PR guidance --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 63957cca16..ad79a04b10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **Testing policy** — [docs/testing.md](docs/testing.md). Every non-trivial model- or product-user-visible behavior change adds or updates a keyless snapshot through a real runnable example in the same PR; package tests, e2e-only assertions, and mock-only fixtures do not substitute for the assembled application transcript. Fixtures must replay on macOS/Linux; fix fixtures, not normalizers. - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. -- **Keep PRs coherent and merge with merge commits.** Split an independently meaningful feature or design decision into a separate or stacked PR when combining it obscures ownership, intent, or verification. Never squash/rebase or rewrite pushed branches; put a review fix on its introducing PR, then merge down the stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). +- **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). - **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 78e57ab53e9ac175f9c6321ced5fe35a7517163e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:28:39 +0800 Subject: [PATCH 69/70] docs: record PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 6 ++ .../2026-07-25-semantic-pr-label-taxonomy.md | 60 +++++++++++++++++++ ...026-07-25-semantic-pr-label-taxonomy.zh.md | 60 +++++++++++++++++++ AGENTS.md | 2 +- 4 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md create mode 100644 .agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml new file mode 100644 index 0000000000..9be3fb4b13 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea +2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md new file mode 100644 index 0000000000..cf25691e55 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -0,0 +1,60 @@ +# Agent Note: Semantic pull request label taxonomy + +Status: implemented + +English | [中文](2026-07-25-semantic-pr-label-taxonomy.zh.md) + +## Problem + +Pull requests need two different signals: what kind of change they make and which repository domains they affect. A flat or broadly named label set conflates those questions, hides work in distinct areas such as `session` and `llm`, and gives reviewers and automation weak inputs. + +The repository also gains new domains over time. Treating today's area labels as a closed set would force future work into inaccurate labels or a generic catch-all. + +## Decision + +Every open or merged pull request carries exactly one kind and every materially affected area. Closed pull requests that were never merged are outside the maintained historical corpus. Other operational labels may coexist, but they do not satisfy either dimension. + +### Kinds + +| Kind | Meaning | +|---|---| +| `feature` | Adds or intentionally changes behavior. | +| `bug-fix` | Corrects incorrect behavior. | +| `doc` | Changes documentation only. | +| `testing` | Changes tests or testing infrastructure without changing product behavior. | +| `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | + +The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. + +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. + +### Current areas + +The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. + +| Group | Areas | +|---|---| +| Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### Extensibility + +The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. + +The kind set stays narrow because kinds are mutually exclusive. A new kind requires a distinct change intent that cannot be represented by the current five; it is not a substitute for an area. + +## Alternatives considered + +- **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. +- **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. +- **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. + +## Consequences + +- Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. +- Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md new file mode 100644 index 0000000000..2978bdc80a --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -0,0 +1,60 @@ +# Agent Note: 语义化 PR 标签分类体系 + +Status: implemented + +[English](2026-07-25-semantic-pr-label-taxonomy.md) | 中文 + +## 问题 + +PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更,以及会影响仓库中的哪些领域。一套扁平或命名宽泛的标签会混淆这两个问题,掩盖 `session`、`llm` 等不同领域的工作,也让评审人和自动化流程得到的输入缺乏有效信息。 + +仓库还会随时间发展出新的领域。如果把当前的领域标签视为封闭集合,未来的工作就只能归入不准确的标签或通用兜底标签。 + +## 决策 + +每项开放或已合并的 PR 都带有恰好一个类型标签,以及所有受到实质影响的领域标签。未合并即关闭的 PR 不属于持续维护的历史记录集合。其他管理用途的标签可以并存,但都不能满足这两个维度中的任一个。 + +### 类型 + +| 类型 | 含义 | +|---|---| +| `feature` | 新增行为或有意改变行为。 | +| `bug-fix` | 修正错误行为。 | +| `doc` | 仅修改文档。 | +| `testing` | 修改测试或测试基础设施,但不改变产品行为。 | +| `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | + +类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 + +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 + +### 当前领域 + +当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 + +| 分组 | 领域 | +|---|---| +| agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | + +### 可扩展性 + +领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 + +类型集合保持精简,因为各类型互斥。新增类型的前提是存在一种当前五类无法表达的独立变更意图;类型不能用来替代领域。 + +## 曾考虑的替代方案 + +- **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 +- **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 +- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 + +## 后果 + +- 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 +- 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。 diff --git a/AGENTS.md b/AGENTS.md index ad79a04b10..a8f811229a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, - **A tool's UI render intent is part of its design**, decided up front (`generic`/`terminal`/`diff`, `locations`); presentation methods are pure functions of `args` ([cookbook](docs/cookbook/adding-a-tool.md)). - **Plan unit, e2e, and snapshot coverage** for new seams, lifecycle shapes, and transcript surfaces; missing snapshot-harness support is part of the implementation, not deferred follow-up. - **Keep PRs coherent; use merge commits.** Split independent features or design decisions when combining them obscures ownership, intent, or verification. Never squash, rebase, or rewrite pushed branches; fix the introducing PR, then merge down its stack ([guide](docs/cookbook/responding-to-pr-review-on-a-stack.md)). -- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the taxonomy remains extensible for recurring distinctions. +- **Label PRs:** exactly one kind (`feature`/`bug-fix`/`doc`/`testing`/`cleanup`), all matching areas; the [taxonomy](.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md) remains extensible for recurring distinctions. - TODO markers: `FIXME`/`TODO`/`XXX` by urgency ([semantics](docs/development.md)). - Files end with exactly one trailing newline; `git diff --cached --check` (pre-commit) gates it. From 586ccb677ec0abd4cab2afd98a98965d5f570341 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:18:03 +0800 Subject: [PATCH 70/70] docs: refine semantic PR label taxonomy --- ...07-25-semantic-pr-label-taxonomy.i18n.yaml | 4 +-- .../2026-07-25-semantic-pr-label-taxonomy.md | 23 ++++++++++++----- ...026-07-25-semantic-pr-label-taxonomy.zh.md | 25 +++++++++++++------ 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml index 9be3fb4b13..8bc3f87435 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.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-25-semantic-pr-label-taxonomy.md: cf25691e55d100cc2cc12246b17f5edbba35feea -2026-07-25-semantic-pr-label-taxonomy.zh.md: 2978bdc80a660f67f84821e473dc229e13b2bc4d +2026-07-25-semantic-pr-label-taxonomy.md: 61b7a829b8c44836cf9c6d0d8a7df463df309d89 +2026-07-25-semantic-pr-label-taxonomy.zh.md: cc0c5e7a8953bc97de51f90349a0e2542be5b77e diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md index cf25691e55..61b7a829b8 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.md @@ -20,26 +20,32 @@ Every open or merged pull request carries exactly one kind and every materially |---|---| | `feature` | Adds or intentionally changes behavior. | | `bug-fix` | Corrects incorrect behavior. | -| `doc` | Changes documentation only. | +| `doc` | Makes documentation the dominant intent. | | `testing` | Changes tests or testing infrastructure without changing product behavior. | | `cleanup` | Preserves behavior while maintaining or simplifying the implementation or repository process. | The kind records the change's dominant intent: accompanying tests and documentation do not turn a feature or bug fix into a testing or documentation change. -Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. A pull request may carry several areas when it changes several domains. +Areas record semantic repository domains rather than temporary initiatives, ownership, or every path touched incidentally. Area labels are not a hierarchy: a pull request may carry several when it changes distinct contracts, but an umbrella and a narrower label do not both describe the same work. ### Current areas -The 43 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. +The 45 current areas are listed below. The group names organize the list for readability; they are not labels or another taxonomy level. | Group | Areas | |---|---| | Agent and model | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| Interfaces | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| Orchestration | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| Capabilities | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| Interfaces | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | Repository and release | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` covers browser and Electron graphical applications, including standalone graphical developer tools; `vscode` remains the editor extension integration. `ui` covers shared cross-interface commands, approval interaction, presentation, and app boot; it coexists with `gui`, `tui`, or a protocol area only when the pull request also changes that shared contract. + +`tasks` owns background work tied to a running process, while `schedule` owns durable time-triggered jobs. `tools` owns generic registry, schema, and execution contracts; a concrete capability receives `tools` only when it changes one of those contracts. `attachment` owns durable media references and multimodal input delivery, while `artifact` owns model-declared deliverable identity and preview lifecycle; neither borrows `tools` or `ui` for its implementation parts. + +Names follow semantic ownership rather than lexical resemblance. `hooks` means the Claude Code and Codex agent bridges, not local Git hooks; `platform` means product portability, not CI runner selection; and `build` means compilation, bundling, and built package artifacts, not documentation generators. + ### Extensibility The area set is intentionally extensible. Add an area when a recurring, meaningful repository domain is missing; do not add a label for one pull request, a temporary project, a status, or a person or team. Rename, split, or retire an area when the domain model changes, and update this list and the affected open and merged pull requests together. @@ -51,10 +57,15 @@ The kind set stays narrow because kinds are mutually exclusive. A new kind requi - **One undifferentiated label set.** Rejected because kind and area answer different questions; mixing them makes the presence of one label say nothing about whether the other dimension was considered. - **A fixed, closed area set.** Rejected because repository domains evolve. A closed set would preserve spelling at the cost of semantic accuracy. - **One broad `core` area or package-derived labels.** Rejected because domains such as `session`, `llm`, and `agent` remain independently meaningful across package boundaries, while incidental file paths are not the scope reviewers or automation need. +- **Separate browser and desktop areas.** Rejected because browser delivery and Electron packaging expose one graphical client domain; splitting them classifies the delivery shell rather than the semantic work. +- **Broad implementation areas in place of a domain.** Rejected because a durable scheduled job is not a background task, an attachment is not merely its source interface or filesystem implementation, and an artifact is not merely its declaring tool or preview interface. +- **Umbrella and leaf areas for the same contract.** Rejected because duplicate labels inflate scope without adding information. Multiple areas remain correct when a pull request changes genuinely distinct contracts. - **Exactly one area per pull request.** Rejected because coherent changes can legitimately span several domains, and dropping secondary areas hides affected contracts. ## Consequences - Reviewers and automation receive one stable intent signal plus a complete semantic scope. +- `gui` queries cover browser and desktop delivery together, while `ui` queries retain only shared cross-interface contracts. +- `schedule`, `attachment`, and `artifact` queries identify those domains directly instead of approximating them through implementation dependencies. - Selecting labels remains a judgment call: paths and title prefixes can suggest areas, but they cannot replace reading the change. - Taxonomy changes carry maintenance work. Area additions, renames, splits, and removals update this decision record and backfill open and merged pull requests so historical queries keep their meaning. diff --git a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md index 2978bdc80a..cc0c5e7a89 100644 --- a/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-07-25-semantic-pr-label-taxonomy.zh.md @@ -20,26 +20,32 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 |---|---| | `feature` | 新增行为或有意改变行为。 | | `bug-fix` | 修正错误行为。 | -| `doc` | 仅修改文档。 | +| `doc` | 以文档变更为主要意图。 | | `testing` | 修改测试或测试基础设施,但不改变产品行为。 | | `cleanup` | 在保持行为不变的前提下,维护或简化实现或仓库流程。 | 类型记录变更的主要意图:配套测试与文档并不会把一项功能或缺陷修复变成测试或文档变更。 -领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。如果一项 PR 修改了多个领域,就可以带有多个领域标签。 +领域记录仓库中的语义领域,而不是临时项目、归属关系或偶然触及的每条路径。领域标签不构成层级:一项 PR 修改不同契约时可以带有多个领域标签,但不能用一个总括标签和一个较窄标签重复描述同一项工作。 ### 当前领域 -当前的 43 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 +当前的 45 个领域如下。分组名称仅用于提高列表的可读性;它们既不是标签,也不是分类体系中的另一个层级。 | 分组 | 领域 | |---|---| | agent(智能体)与模型 | `agent`, `agent-loop`, `session`, `llm`, `model-context`, `compaction`, `tools`, `persistence` | -| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `telemetry`, `storage`, `workspace` | -| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `sandbox`, `mcp`, `hooks`, `cordis` | -| 接口 | `ui`, `web`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `desktop`, `vscode`, `website` | +| 编排 | `subagent`, `workflow`, `planning`, `tasks`, `schedule`, `telemetry`, `storage`, `workspace` | +| 能力 | `bash`, `pty`, `filesystem`, `lsp`, `skills`, `web-search`, `code-mode`, `artifact`, `attachment`, `sandbox`, `mcp`, `hooks`, `cordis` | +| 接口 | `ui`, `gui`, `tui`, `acp`, `json-rpc`, `cli`, `python-sdk`, `vscode`, `website` | | 仓库与发布 | `dev-infra`, `ci`, `build`, `dependencies`, `platform`, `i18n`, `release` | +`gui` 涵盖浏览器和 Electron 图形应用,包括独立的图形化开发者工具;`vscode` 仍表示编辑器扩展集成。`ui` 涵盖共享的跨接口命令、审批交互、呈现和应用启动;只有当 PR 还修改这项共享契约时,它才与 `gui`、`tui` 或某个协议领域并用。 + +`tasks` 负责与运行中进程绑定的后台工作,`schedule` 则负责持久化的定时作业。`tools` 负责通用的注册表契约、schema 契约和执行契约;具体能力只有在修改其中一项契约时才带有 `tools`。`attachment` 负责持久化的媒体引用和多模态输入传递,`artifact` 则负责模型声明的交付物标识和预览生命周期;二者都不会因实现包含工具或界面部分而借用 `tools` 或 `ui`。 + +标签名称以语义归属为准,而不是词面相似性。`hooks` 指 Claude Code 和 Codex 的 agent 桥接,而不是本地 Git 钩子;`platform` 指产品可移植性,而不是 CI 运行器选择;`build` 指编译、打包和已构建的包(package)产物,而不是文档生成器。 + ### 可扩展性 领域集合有意保持可扩展。当分类体系缺少一个会反复涉及且具有实际意义的仓库领域时,就新增领域;不要仅为一项 PR、临时项目、状态、个人或团队新增标签。当领域模型发生变化时,重命名、拆分或退役相应领域,同时更新本列表以及所有受影响的开放和已合并 PR。 @@ -50,11 +56,16 @@ PR(Pull Request)需要传达两个不同的信号:它带来哪一类变更 - **一套不区分维度的标签。** 不予采纳,因为类型与领域回答的是不同问题;两者混在一起时,存在一个维度的标签并不表示另一个维度也经过了考虑。 - **一套固定、封闭的领域集合。** 不予采纳,因为仓库领域会持续演变。封闭集合会以牺牲语义准确性为代价来维持拼写不变。 -- **一个宽泛的 `core` 领域,或从包(package)结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **一个宽泛的 `core` 领域,或从包结构派生的标签。** 不予采纳,因为 `session`、`llm` 和 `agent` 等领域在跨越包边界时仍各自具有意义,而偶然涉及的文件路径并不是评审人或自动化流程所需的范围信息。 +- **为浏览器和桌面端分别设置领域。** 不予采纳,因为浏览器交付和 Electron 打包共同呈现同一个图形客户端领域;拆开二者将按交付形态而非工作的语义进行分类。 +- **以宽泛的实现领域替代语义领域。** 不予采纳,因为持久化的定时作业不是后台任务,附件不只是其来源接口或文件系统实现,产物也不只是声明它的工具或预览接口。 +- **同一项契约同时使用总括领域与细分领域。** 不予采纳,因为重复标签只会虚增范围,不会增加信息。一项 PR 确实修改不同契约时,多个领域标签仍然合理。 - **每项 PR 恰好一个领域。** 不予采纳,因为一项内聚的变更可以合理地跨越多个领域;省略次要领域会隐藏受影响的契约。 ## 后果 - 评审人和自动化流程获得一个稳定的意图信号,以及完整的语义范围。 +- `gui` 查询会同时覆盖浏览器与桌面端交付,`ui` 查询则只涵盖共享的跨接口契约。 +- `schedule`、`attachment` 与 `artifact` 查询直接对应各自领域,无需通过实现依赖近似归类。 - 选择标签仍然需要判断:路径和标题前缀可以提示领域,但不能替代阅读变更内容。 - 变更分类体系会产生维护工作。新增、重命名、拆分或移除领域时,需要更新本决策记录,并回填开放和已合并的 PR,使历史查询保持原有含义。