mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into worktree/dsh-arg-parser
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 路径则验证单个导出的插件能够注册组合后的服务。
|
||||
|
||||
@@ -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-10-sqlite-session-query-provider.md: be8795b609b52eeb03268c4986b52004eef0dba9
|
||||
2026-07-10-sqlite-session-query-provider.md: 98618a7eb572ce59c5fa5984675c9dc57b3f4289
|
||||
2026-07-10-sqlite-session-query-provider.zh.md: bb3650da907cf86a853f748fa0ee40d5c2168709
|
||||
|
||||
@@ -34,13 +34,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. Persistent and TEMP session metadata store the integer `SessionHeader.createdAt` contract in strict `INTEGER` columns. 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
|
||||
|
||||
@@ -54,6 +54,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.
|
||||
|
||||
@@ -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: 2a9a20a8b39dea309e759f4eb6ddcdabe25dd8be
|
||||
2026-07-24-model-facing-session-query-tools.zh.md: 7fbe746681b329b3e50ae74608a8b2e5167c5ae9
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
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. 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.
|
||||
|
||||
## Host composition
|
||||
|
||||
The consumer is an opt-in plugin. The shipped ACP and TUI apps mount `ctx.sessionQuery` for non-model consumers, while the shared Web/headless composition mounts neither the query service nor the consumer. No shipped composition mounts `@deepseek-ai/dsh-tool-session-query`, so 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
|
||||
|
||||
- **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, 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. Shipped configuration, app composition tests, and assembled ACP request-header snapshots prove that the model-facing consumer remains absent while `ctx.sessionQuery` stays available where mounted. 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
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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 实现,负责模型参数校验与易读文本渲染,并贡献一个精简的提示词段,说明历史搜索以及从搜索转向追踪/读取的工作流。
|
||||
|
||||
该包入口仅作为配置、提示词注册与工具注册的公开组合根。内部模块沿执行边界划分:`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` 可通过声明合并扩展,事件类型字符串保持开放;可用状态与事件表层使用封闭取值。
|
||||
|
||||
## 工作区权限
|
||||
|
||||
每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。
|
||||
|
||||
每个受信任的 `ctx.sessionQuery` 调用都会经过同一个模型边界净化器。它首先检查执行信号,准确保留调用者取消。对于其他失败,它会尽力把可获得的语料或提供方诊断链写入内部日志;当值无法安全检查时,则改用固定占位符。诊断格式化与错误分类各自受到保护,因此无法打印的嵌套 cause 既不会逃逸,也不会阻止对外层错误进行安全分类;分类不安全或日志记录失败时,则返回固定的通用错误码 `SESSION_QUERY_TOOL_FAILED` 及其消息。逐标题失败也会先经过同一个净化器,再转为不可用标记。工具自身的输入校验与授权错误在该服务边界之外创建,因此仍保留精确消息。
|
||||
|
||||
搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。
|
||||
|
||||
## 无游标结果与 spill
|
||||
|
||||
两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。
|
||||
|
||||
追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。
|
||||
|
||||
会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。
|
||||
|
||||
## 宿主组合
|
||||
|
||||
该消费方是一个需显式启用的插件。发布的 ACP 与 TUI 应用为非模型消费方挂载 `ctx.sessionQuery`,而 Web/headless 共享组合既不挂载查询服务,也不挂载该消费方。发布的组合均未挂载 `@deepseek-ai/dsh-tool-session-query`,因此默认模型请求中不包含查询提示词或 schema。选择启用该插件的组合还要决定是否挂载通用的超时与 spill 策略;专用的 ACP 快照 fixture(测试前置数据)同时挂载这两项策略,并使用私有的本地 spill 存储。通用工具表现无需会话查询专用客户端插件。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **向模型公开提供方游标**:不予采纳,因为记录工具结果或开始下一个模型步骤会改变相关会话或全局代,导致游标通常在模型能够复用前就已过期。
|
||||
- **增加工具本地截断、偏移量或 spill 文件**:不予采纳,因为执行后 spill 策略已经统一负责各工具的完整结果保留与读取。
|
||||
- **允许访问所有持久化会话或由模型提供工作区过滤条件**:不予采纳,因为 `ctx.sessionQuery` 是受信任服务,面向模型的消费者必须执行调用者权限边界。
|
||||
- **把搜索、追踪与精确读取合并为一个带操作选择器的工具**:不予采纳,因为职责单一的名称能为模型提供更清晰的 schema、默认值、表现意图与后续选择。
|
||||
- **只返回一层谱系**:不予采纳,因为 spill 已消除行内大小方面的理由,而单层输出会遗漏关系且没有继续读取路径。
|
||||
|
||||
## 验证
|
||||
|
||||
包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、父级过滤预授权与抵御预言机探测、变更观测拒绝、普通值与对抗性未知值的服务诊断脱敏、尽力记录循环 cause、日志失败隔离、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。发布配置、应用组合测试与组装后的 ACP 请求头快照证明:面向模型的消费方仍未挂载,而 `ctx.sessionQuery` 在已经挂载该服务的组合中保持可用。包自身的 Loader 冒烟测试与专用无密钥 ACP 快照显式挂载该消费方,并配套启用超时与 spill 支持,固定其提示词指引、schema 以及与路径无关的精确事件读取保留行为。
|
||||
|
||||
## 后果
|
||||
|
||||
模型无需获得存储权限或继续状态,即可通过与提供方无关的方式访问既往会话工作。搜索具有有限的单次调用工作边界,若要命中前 100 条以后的结果,可能需要缩小查询;搜索调用不能与兄弟工具重叠执行,而精确观测仍可并行调度。完整追踪与事件负载可能表现为 spill 引用而不是行内文本。严格的 `cwd` 字符串相等选择了保守安全边界,而不解析通过符号链接等价的路径。自定义组合可以在不挂载 spill 的情况下使用该工具,但这表示它们明确接受完整追踪与读取结果直接出现在行内。
|
||||
@@ -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: 38a0b704e3ed8be11b59743a0f76f7ce09c06323
|
||||
architecture.zh.md: e0e381a81d7d1ac8e18d2b8fac28b1d3d8903ff5
|
||||
architecture.md: b426891c0483f42a64b597632cf1871aff79ca2d
|
||||
architecture.zh.md: 13feefb6854e79ddee38602902d325a789fd7744
|
||||
|
||||
@@ -43,7 +43,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts with package-contributed servi
|
||||
| `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 |
|
||||
|
||||
|
||||
@@ -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) | 按包名筛选包自有运行时检查的注册表 |
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ flowchart LR
|
||||
pkg_apiproxy["apiproxy"]
|
||||
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
|
||||
pkg_session_reference["session-reference"]
|
||||
pkg_tool_session_query["tool-session-query"]
|
||||
svc_sessionReferences["ctx.sessionReferences<br/>Cross-session snapshot preparation"]
|
||||
pkg_tui["tui"]
|
||||
pkg_session_title["session-title"]
|
||||
@@ -244,6 +245,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_tui
|
||||
svc_sessions --> pkg_agent
|
||||
svc_sessions --> pkg_agent_loop
|
||||
@@ -300,7 +302,7 @@ flowchart LR
|
||||
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
|
||||
| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. |
|
||||
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. |
|
||||
| `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) | - | 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. |
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory for JSONL sessions. Defaults to `./.sessions`. */
|
||||
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
|
||||
packChunks?: boolean
|
||||
@@ -80,7 +80,7 @@ export interface Config {
|
||||
|
||||
Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools)
|
||||
|
||||
Source: [`packages/examples/acp-demo/src/index.ts:37`](../packages/examples/acp-demo/src/index.ts)
|
||||
Source: [`packages/examples/acp-demo/src/index.ts:39`](../packages/examples/acp-demo/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-agent-loop`
|
||||
|
||||
@@ -1064,6 +1064,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. */
|
||||
@@ -1072,7 +1074,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:76`](../packages/session-query/session-query-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-session-reference`
|
||||
|
||||
@@ -1546,6 +1548,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:29`](../packages/session-query/tool-session-query/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-tool-skill`
|
||||
|
||||
Requires: `tools` · `skills`
|
||||
|
||||
@@ -1009,15 +1009,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<SessionHeader[]>
|
||||
abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
|
||||
|
||||
/**
|
||||
* List materialized sessions with cheap per-log change tokens.
|
||||
@@ -1026,9 +1028,10 @@ abstract list(): Promise<SessionHeader[]>
|
||||
* 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<SessionPersistenceSnapshot[]>
|
||||
abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
|
||||
```
|
||||
|
||||
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)
|
||||
@@ -1054,15 +1057,16 @@ 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<SessionSearchPage<SessionEventSearchHit>>
|
||||
abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise<SessionEventSearchPage>
|
||||
|
||||
/**
|
||||
* 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<SessionRecord[]>
|
||||
listSessions(signal?: AbortSignal): Promise<SessionRecord[]>
|
||||
|
||||
/**
|
||||
* Read and replay-validate one complete logical session log without making it live.
|
||||
@@ -1075,16 +1079,37 @@ async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>
|
||||
/**
|
||||
* 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<SessionRecord[]>
|
||||
async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise<SessionRecord[]>
|
||||
|
||||
/**
|
||||
* 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<SessionTitleSnapshot | undefined>
|
||||
async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionTitleSnapshot | undefined>
|
||||
|
||||
/**
|
||||
* 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, signal?: AbortSignal, ): Promise<SessionTitleObservation>
|
||||
|
||||
/**
|
||||
* 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<SessionTitleObservationResult[]>
|
||||
|
||||
/**
|
||||
* List lightweight raw-log event records for one logical session.
|
||||
@@ -1112,30 +1137,33 @@ async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot>
|
||||
/**
|
||||
* 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<SessionLineageTrace>
|
||||
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @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<SessionEventTrace>
|
||||
async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation>
|
||||
|
||||
/**
|
||||
* 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<SessionEventWindow>
|
||||
async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow>
|
||||
```
|
||||
|
||||
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) · [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) · [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:74`](../../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`
|
||||
|
||||
|
||||
@@ -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
|
||||
persistence.md: b03cc07d2e514b3900d4035ea386f31c761470a7
|
||||
persistence.md: 4ec967873e946c8f185f8a8f497f2af4a363474e
|
||||
persistence.zh.md: 3030ff2fe949cb02385331800d826df227e3d6cd
|
||||
|
||||
@@ -128,7 +128,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.
|
||||
|
||||
@@ -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
|
||||
session-query.md: c6dde8714a0875d46cf7b49cc181daab8f44afe0
|
||||
session-query.zh.md: 44be8b7f89e1576e54c8dc3cb60619d6728c6895
|
||||
session-query.md: d92af4bac34f7d41457e9e193111c3a53fe8022e
|
||||
session-query.zh.md: ecf330b0a361ffae352a91c0d35524444936606d
|
||||
|
||||
@@ -51,6 +51,39 @@ 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. 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. */
|
||||
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
|
||||
/** 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 {
|
||||
@@ -158,6 +191,16 @@ interface SessionSearchPage<T> {
|
||||
}
|
||||
```
|
||||
|
||||
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<SessionEventSearchHit> {
|
||||
/** 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 {
|
||||
@@ -279,6 +322,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.
|
||||
|
||||
@@ -51,6 +51,39 @@ interface SessionSurfaceSnapshot {
|
||||
}
|
||||
```
|
||||
|
||||
`SessionTitleObservation` 将同样的原子观测规则应用于标题折叠,使授权消费者能够验证提供标题的源 header。批量读取会按顺序为每个唯一请求 id 返回一个 `SessionTitleObservationResult`:操作失败只影响对应 id,而取消会拒绝整个操作。
|
||||
|
||||
```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
|
||||
/** 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 {
|
||||
@@ -158,6 +191,16 @@ interface SessionSearchPage<T> {
|
||||
}
|
||||
```
|
||||
|
||||
与跨会话分组 hit 不同,会话内搜索即使没有命中项,也必须公开它观测到的目标 header。
|
||||
|
||||
```ts type-equiv
|
||||
/** Event-search results bound to the indexed target-session observation. */
|
||||
interface SessionEventSearchPage extends SessionSearchPage<SessionEventSearchHit> {
|
||||
/** 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 {
|
||||
@@ -279,6 +322,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
|
||||
}
|
||||
```
|
||||
|
||||
## 错误
|
||||
|
||||
封闭的 code 联合类型区分请求校验、目标缺失、surface 日志格式错误、可选后端故障与矛盾的源元数据。
|
||||
|
||||
@@ -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"]
|
||||
@@ -603,6 +604,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
|
||||
@@ -753,6 +761,8 @@ flowchart TD
|
||||
pkg_acp_demo --> pkg_invariants
|
||||
pkg_acp_demo --> pkg_session_checkpoint_policy
|
||||
pkg_acp_demo --> pkg_session_persistence_jsonl
|
||||
pkg_acp_demo --> pkg_session_query
|
||||
pkg_acp_demo --> pkg_session_query_sqlite
|
||||
pkg_acp_demo --> pkg_tools
|
||||
pkg_acp_demo --> pkg_workspace_context
|
||||
pkg_cli_demo --> pkg_agent
|
||||
@@ -899,6 +909,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) |
|
||||
@@ -919,6 +930,6 @@ flowchart TD
|
||||
| [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
|
||||
| [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| [`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) |
|
||||
| [`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), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`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), [`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) |
|
||||
|
||||
@@ -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. 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. |
|
||||
@@ -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. 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`
|
||||
|
||||
@@ -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, model-facing tools, 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) adds local tool-result spill storage for dedicated 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
|
||||
|
||||
|
||||
12
examples/acp-agent/session-query.cordis.snapshot.yml
Normal file
12
examples/acp-agent/session-query.cordis.snapshot.yml
Normal file
@@ -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'
|
||||
12
examples/acp-agent/session-query.cordis.yml
Normal file
12
examples/acp-agent/session-query.cordis.yml
Normal file
@@ -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'
|
||||
@@ -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))
|
||||
@@ -88,6 +89,15 @@ const SCENARIOS: Scenario[] = [
|
||||
configPath: FS_CONFIG,
|
||||
},
|
||||
{ name: 'bash-spill', hasModelTurn: true, recorded: false, configPath: FS_CONFIG },
|
||||
{
|
||||
name: 'session-query-spill',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
pinsHeader: true,
|
||||
headerClass: 'session-query',
|
||||
configPath: SESSION_QUERY_CONFIG,
|
||||
posixOnly: true,
|
||||
},
|
||||
{
|
||||
name: 'pty-tools',
|
||||
hasModelTurn: true,
|
||||
|
||||
@@ -129,8 +129,8 @@
|
||||
{"type":"assistant/chunk","seq":127,"time":1783860677493,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":128,"time":1784821261753,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"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,61,62,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,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":129,"time":1784821261754,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}}
|
||||
{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"5b05e297-b792-4b0f-9830-d2a55eb70d7d","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"5b05e297-b792-4b0f-9830-d2a55eb70d7d","outcome":"allowed-once"}}
|
||||
{"type":"approval/asked","seq":130,"time":1784821261758,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}}
|
||||
{"type":"approval/decided","seq":131,"time":1784821261759,"data":{"id":"bc159170-7ce0-4162-a6c4-ed41d4ca582f","outcome":"allowed-once"}}
|
||||
{"type":"tool/result","seq":132,"time":1784821261775,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[129],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":133,"time":1784821261781,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":134,"time":1784821261782,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -87,8 +87,8 @@
|
||||
{"type":"assistant/chunk","seq":85,"time":1784045703749,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":86,"time":1784821264893,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."},{"type":"tool-call","id":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3871,"outputTokens":132,"cacheReadTokens":0,"reasoningTokens":23}},"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,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":87,"time":1784821264893,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","name":"write","arguments":"{\"file_path\": \"escalated.md\", \"content\": \"escalated\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to escalate this write\"}"}}
|
||||
{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"3fa5f6a5-407f-4079-bb26-8eae76e53330","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
|
||||
{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"3fa5f6a5-407f-4079-bb26-8eae76e53330","outcome":"allowed-once"}}
|
||||
{"type":"approval/asked","seq":88,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","toolName":"write","callId":"call_00_Fnymmavpr4klMDy4Fdej3227","reason":"escalate sandbox to danger-full-access: the user asked to escalate this write"}}
|
||||
{"type":"approval/decided","seq":89,"time":1784821264898,"data":{"id":"9e3fc97b-19e4-44a1-8ff1-795683948bcd","outcome":"allowed-once"}}
|
||||
{"type":"tool/result","seq":90,"time":1784821264906,"data":{"turn":1,"step":1,"callId":"call_00_Fnymmavpr4klMDy4Fdej3227","content":[{"type":"text","text":"<path>/private/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-vmEGzd/escalated.md</path>\n<type>file</type>\n<content>\nCreated file\n</content>"}],"isError":false,"meta":{"diffs":[]}},"sourceEventSeqs":[87],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":91,"time":1784821264911,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":92,"time":1784821264912,"data":{"turn":1,"step":2}}
|
||||
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
@@ -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 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"}}}
|
||||
{"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); 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}}
|
||||
{"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"}}}
|
||||
@@ -0,0 +1,4 @@
|
||||
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}}
|
||||
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
|
||||
{"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"}}
|
||||
@@ -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.
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -60,6 +60,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:*",
|
||||
|
||||
@@ -23,9 +23,12 @@ class TestSessionQueryService extends SessionQueryService {
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
..._args: Parameters<SessionQueryService['searchEvents']>
|
||||
...args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
return this.readSurface(args[0].sessionId).then(surface => ({
|
||||
session: surface.session,
|
||||
items: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -509,16 +509,16 @@ 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<SessionHeader[]>',
|
||||
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<SessionHeader[]>',
|
||||
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<SessionPersistenceSnapshot[]>',
|
||||
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<SessionPersistenceSnapshot[]>',
|
||||
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 */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -531,24 +531,32 @@ 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<SessionSearchPage<SessionEventSearchHit>>',
|
||||
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<SessionEventSearchPage>',
|
||||
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<SessionRecord[]>',
|
||||
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<SessionRecord[]>',
|
||||
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<SessionLogSnapshot>',
|
||||
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<SessionRecord[]>',
|
||||
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<SessionRecord[]>',
|
||||
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): Promise<SessionTitleSnapshot | undefined>',
|
||||
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<SessionTitleSnapshot | undefined>',
|
||||
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, signal?: AbortSignal, ): Promise<SessionTitleObservation>',
|
||||
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<SessionTitleObservationResult[]>',
|
||||
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<SessionEventRecord[]>',
|
||||
@@ -563,16 +571,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<SessionLineageTrace>',
|
||||
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<SessionLineageTrace>',
|
||||
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<SessionEventTrace>',
|
||||
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, signal?: AbortSignal): Promise<SessionEventTraceObservation>',
|
||||
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<SessionEventWindow>',
|
||||
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<SessionEventWindow>',
|
||||
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 */',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1944,6 +1952,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<SessionEventSearchHit> {\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}',
|
||||
@@ -1956,6 +1968,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}',
|
||||
@@ -2064,6 +2080,14 @@ 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: '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<SessionTitleProviderResult>;\n}',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -9,9 +9,10 @@ ACP automation server app: the default agent spine, client-created agents throug
|
||||
| `@deepseek-ai/dsh-agent-spine-demo` | Providerless agent spine with no pre-created agents; `session/new` creates each agent. |
|
||||
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session logs used by checkpointing, observability, and snapshot replay. |
|
||||
| `@deepseek-ai/dsh-session-checkpoint-policy` | Durability barriers before model calls and top-level tool effects, plus completed-step checkpoints. |
|
||||
| `@deepseek-ai/dsh-session-query-sqlite` | Derived exact/FTS session-query service, opened before the ACP transport so leaf consumers are ready for the first model request. |
|
||||
| `@deepseek-ai/dsh-acp` | Automation-only ACP transport over stdin/stdout. |
|
||||
|
||||
The app does not install commands, user interaction, session navigation, configuration pickers, or a stdout logger. It owns the four plugins through one ordered effect so ACP sessions quiesce before checkpointing and persistence detach. Leaf configurations supply LLM, executor, sandbox, approval, filesystem, and model-facing tool plugins.
|
||||
The app does not install commands, user interaction, session navigation, configuration pickers, or a stdout logger. It owns these plugins through one ordered effect so the query service is ready before ACP accepts work and ACP sessions quiesce before checkpointing and persistence detach. Leaf configurations supply LLM, executor, sandbox, approval, filesystem, and model-facing tool plugins.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -25,7 +26,7 @@ The app does not install commands, user interaction, session navigation, configu
|
||||
| `tools` | `{ mode: 'native' }` | Native, Code Mode, or combined model tool transport. |
|
||||
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home shared by bash and local skill discovery. |
|
||||
| `sessionTitle` | spine example limits | Durable fallback-title limits; titles remain off the ACP wire. |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL backend root. |
|
||||
| `persistenceRoot` | `./.sessions` | JSONL backend root and parent directory of the derived `session-query.db` index. |
|
||||
| `packChunks` | `false` | Pack consecutive delta-chunk events in storage. |
|
||||
| `persistenceCompression` | `zstd` | Checksummed Zstandard frames or raw `none`. |
|
||||
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
|
||||
@@ -35,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, 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
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
@@ -58,6 +60,8 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workspace-context": "workspace:^",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { join } from 'node:path'
|
||||
import z from 'schemastery'
|
||||
import * as acp from '@deepseek-ai/dsh-acp'
|
||||
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
|
||||
@@ -22,6 +23,7 @@ import SessionPersistenceJsonl, {
|
||||
type JsonlCompression,
|
||||
} from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
|
||||
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
|
||||
export const name = 'acp-demo'
|
||||
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
|
||||
@@ -51,7 +53,7 @@ export interface Config {
|
||||
dshHome?: string
|
||||
/** Fallback session-title limits forwarded through agent-spine-demo. */
|
||||
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
|
||||
/** Directory for JSONL sessions. Defaults to `./.sessions`. */
|
||||
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
|
||||
persistenceRoot?: string
|
||||
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
|
||||
packChunks?: boolean
|
||||
@@ -101,28 +103,39 @@ export const Config: z<Config> = z.object({
|
||||
/**
|
||||
* Compose the spine with the ACP automation transport. The agent-spine-demo bundle pre-creates
|
||||
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
|
||||
* `persona`; the JSONL backend persists under
|
||||
* `persona`; the JSONL backend and derived query index persist under
|
||||
* `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one
|
||||
* agent per `session/new` from the provider/model pair. The composite effect
|
||||
* unloads in reverse order, keeping checkpoint and persistence listeners
|
||||
* attached until ACP agents have flushed their closing events. No logger, no
|
||||
* `hmr` — stdout stays pure.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
const goals = config.goals ?? {}
|
||||
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
|
||||
ctx.effect(function* () {
|
||||
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
|
||||
await ctx.effect(async function* () {
|
||||
const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
|
||||
await spine
|
||||
yield spine.dispose
|
||||
// Same rationale as the Config schema above: each front door forwards its own
|
||||
// persistence passthroughs rather than sharing a facade with stdio-demo.
|
||||
/* jscpd:ignore-start */
|
||||
yield ctx.plugin(SessionPersistenceJsonl, {
|
||||
const persistence = ctx.plugin(SessionPersistenceJsonl, {
|
||||
root: persistenceRoot,
|
||||
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
|
||||
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
|
||||
}).dispose
|
||||
})
|
||||
await persistence
|
||||
yield persistence.dispose
|
||||
/* jscpd:ignore-end */
|
||||
yield ctx.plugin(sessionCheckpointPolicy).dispose
|
||||
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
|
||||
const checkpoint = ctx.plugin(sessionCheckpointPolicy)
|
||||
await checkpoint
|
||||
yield checkpoint.dispose
|
||||
const query = ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
|
||||
await query
|
||||
yield query.dispose
|
||||
const transport = ctx.plugin(acp, { provider: config.provider, model: config.model })
|
||||
await transport
|
||||
yield transport.dispose
|
||||
}, 'acp-demo.composition')
|
||||
}
|
||||
|
||||
@@ -30,9 +30,6 @@ async function mount(config: acpAgent.Config, withBash = false): Promise<Context
|
||||
})
|
||||
}
|
||||
await ctx.plugin(acpAgent, config)
|
||||
// The bundle mounts its children inside apply() (not awaited there); let their
|
||||
// fibers settle so the spine services are ready.
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -89,7 +86,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
expect(ctx.get('agents')).toBeDefined()
|
||||
expect(ctx.get('sessions')).toBeDefined()
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
expect(ctx.get('sessionQuery')).toBeUndefined()
|
||||
expect(ctx.get('sessionQuery')).toBeDefined()
|
||||
expect(ctx.get('sessionReferences')).toBeUndefined()
|
||||
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
|
||||
expect(ctx.get('agentLoop')).toBeDefined()
|
||||
@@ -122,8 +119,12 @@ describe('dsh-acp-demo composition', () => {
|
||||
// persistenceRoot, so the runtime fallback is the one that fires.
|
||||
const ctx = new Context()
|
||||
// No persona: covers the omitted-persona forwarding branch too.
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await acpAgent.apply(ctx, {
|
||||
provider: 'mock',
|
||||
model: 'mock',
|
||||
skills: await isolatedSkillsConfig(),
|
||||
workspaceContext: false,
|
||||
})
|
||||
expect(ctx.get('sessionPersistence')).toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
@@ -144,8 +145,7 @@ describe('dsh-acp-demo composition', () => {
|
||||
it('uses default skill config when apply is called directly without skills', async () => {
|
||||
await withIsolatedSkillHomes(async () => {
|
||||
const ctx = new Context()
|
||||
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
await acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
|
||||
expect(ctx.skills).toBeDefined()
|
||||
expect(await ctx.skills.list()).toEqual([])
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
@@ -28,8 +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 — 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'
|
||||
@@ -44,6 +44,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 {
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query-sqlite"
|
||||
},
|
||||
{
|
||||
"path": "../agent-spine-demo"
|
||||
},
|
||||
|
||||
@@ -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; 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 |
|
||||
|
||||
@@ -41,7 +41,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
|
||||
|
||||
|
||||
@@ -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 project directories when cwd is unknown. */
|
||||
async loadStored(id: SessionId): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
|
||||
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<JsonlTornMarker> | 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<StoredPrefix<JsonlTornMarker>> {
|
||||
const buffer = await readFile(path)
|
||||
private async readPrefix(
|
||||
path: string,
|
||||
expectedId?: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredPrefix<JsonlTornMarker>> {
|
||||
const buffer = await readFile(path, { signal })
|
||||
signal?.throwIfAborted()
|
||||
let prefix: StoredPrefix<JsonlTornMarker>
|
||||
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,46 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
: {},
|
||||
}
|
||||
}
|
||||
await this.assertStoredIdentity(path, prefix.meta, expectedId)
|
||||
signal?.throwIfAborted()
|
||||
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
|
||||
signal?.throwIfAborted()
|
||||
return prefix
|
||||
}
|
||||
|
||||
/** Decode complete frames and retain complete JSONL records from a torn final frame. */
|
||||
private async readZstdPrefix(buffer: Buffer): Promise<StoredPrefix<JsonlTornMarker>> {
|
||||
private async readZstdPrefix(
|
||||
buffer: Buffer,
|
||||
signal?: AbortSignal,
|
||||
): Promise<StoredPrefix<JsonlTornMarker>> {
|
||||
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 +226,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,16 +277,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** List valid unique stored sessions' metadata (header line only — no full-log parse). */
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
return (await this.listArtifacts()).map(artifact => artifact.header)
|
||||
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
return (await this.listArtifacts(signal)).map(artifact => artifact.header)
|
||||
}
|
||||
|
||||
/** List metadata plus a stat-derived identity for each append-only log. */
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
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([
|
||||
@@ -268,30 +300,42 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
].join(':')),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
if (!isENOENT(error)) throw error
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return snapshots
|
||||
}
|
||||
|
||||
private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> {
|
||||
private async listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>> {
|
||||
signal?.throwIfAborted()
|
||||
await this.ensureRootEncoding()
|
||||
signal?.throwIfAborted()
|
||||
const artifacts: Array<{ header: SessionHeader; path: string }> = []
|
||||
const ids = new Set<SessionId>()
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
for (const dir of await this.listSessionDirs(project)) {
|
||||
for (const project of await this.listProjectDirs(signal)) {
|
||||
signal?.throwIfAborted()
|
||||
for (const dir of await this.listSessionDirs(project, signal)) {
|
||||
signal?.throwIfAborted()
|
||||
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
|
||||
const oppositeExists = await this.exists(opposite)
|
||||
signal?.throwIfAborted()
|
||||
if (oppositeExists) throw this.encodingMismatch(opposite)
|
||||
const path = join(dir, `session${logSuffix(this.compression)}`)
|
||||
if (!await this.exists(path)) continue
|
||||
const pathExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (!pathExists) continue
|
||||
// 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
|
||||
await this.assertStoredIdentity(path, meta)
|
||||
await this.assertStoredIdentity(path, meta, undefined, signal)
|
||||
signal?.throwIfAborted()
|
||||
if (ids.has(meta.id)) {
|
||||
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
|
||||
}
|
||||
@@ -299,6 +343,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
artifacts.push({ header: meta, path })
|
||||
}
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return artifacts
|
||||
}
|
||||
|
||||
@@ -501,18 +546,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<string | undefined> {
|
||||
private async readFirstLine(path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
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))
|
||||
@@ -523,23 +573,34 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** Read and validate only the independently compressed header frame. */
|
||||
private async readFirstZstdLine(path: string): Promise<string | undefined> {
|
||||
private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
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')
|
||||
}
|
||||
@@ -551,19 +612,26 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** Find the unique physical log for an id across every project directory. */
|
||||
private async findLog(id: SessionId): Promise<string | undefined> {
|
||||
private async findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
|
||||
const matches: string[] = []
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
await this.rejectLegacyFlatArtifact(project, id)
|
||||
for (const project of await this.listProjectDirs(signal)) {
|
||||
signal?.throwIfAborted()
|
||||
await this.rejectLegacyFlatArtifact(project, id, signal)
|
||||
signal?.throwIfAborted()
|
||||
const dir = join(project, encodeSegment(id))
|
||||
const path = join(dir, `session${logSuffix(this.compression)}`)
|
||||
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
|
||||
if (await this.exists(path)) matches.push(path)
|
||||
const oppositeExists = await this.exists(opposite)
|
||||
signal?.throwIfAborted()
|
||||
if (oppositeExists) throw this.encodingMismatch(opposite)
|
||||
const pathExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (pathExists) matches.push(path)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
return matches[0]
|
||||
}
|
||||
|
||||
@@ -578,7 +646,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** Reject metadata that does not identify the selected physical log. */
|
||||
private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise<void> {
|
||||
private async assertStoredIdentity(
|
||||
path: string,
|
||||
meta: SessionHeader,
|
||||
expectedId?: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
if (expectedId !== undefined && meta.id !== expectedId) {
|
||||
throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
|
||||
}
|
||||
@@ -588,9 +662,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
} catch (error) {
|
||||
throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error })
|
||||
}
|
||||
if (path !== expectedPath && !await this.sameFile(path, expectedPath)) {
|
||||
if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) {
|
||||
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
|
||||
}
|
||||
signal?.throwIfAborted()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -598,11 +673,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
* case aliases on case-insensitive filesystems without weakening identity
|
||||
* checks on case-sensitive stores.
|
||||
*/
|
||||
private async sameFile(path: string, expectedPath: string): Promise<boolean> {
|
||||
private async sameFile(path: string, expectedPath: string, signal?: AbortSignal): Promise<boolean> {
|
||||
signal?.throwIfAborted()
|
||||
try {
|
||||
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)])
|
||||
signal?.throwIfAborted()
|
||||
return actual === expected
|
||||
} catch (error) {
|
||||
signal?.throwIfAborted()
|
||||
/* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
|
||||
if (isENOENT(error)) return false
|
||||
/* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
|
||||
@@ -611,9 +689,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** The human-readable project directories under the configured root. */
|
||||
private async listProjectDirs(): Promise<string[]> {
|
||||
private async listProjectDirs(signal?: AbortSignal): Promise<string[]> {
|
||||
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.
|
||||
@@ -623,8 +703,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** List session-owned directories and reject the obsolete flat-file layout. */
|
||||
private async listSessionDirs(project: string): Promise<string[]> {
|
||||
private async listSessionDirs(project: string, signal?: AbortSignal): Promise<string[]> {
|
||||
signal?.throwIfAborted()
|
||||
const entries = await readdir(project, { withFileTypes: true })
|
||||
signal?.throwIfAborted()
|
||||
const legacy = entries.find(entry =>
|
||||
entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zstd')))
|
||||
if (legacy !== undefined) throw this.legacyLayout(join(project, legacy.name))
|
||||
@@ -646,11 +728,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectLegacyFlatArtifact(project: string, id: SessionId): Promise<void> {
|
||||
private async rejectLegacyFlatArtifact(
|
||||
project: string,
|
||||
id: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted()
|
||||
const encoded = encodeSegment(id)
|
||||
for (const compression of ['zstd', 'none'] as const) {
|
||||
const path = join(project, encoded + logSuffix(compression))
|
||||
if (await this.exists(path)) throw this.legacyLayout(path)
|
||||
const artifactExists = await this.exists(path)
|
||||
signal?.throwIfAborted()
|
||||
if (artifactExists) throw this.legacyLayout(path)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -275,6 +275,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<Array<{ header: SessionHeader; path: string }>>
|
||||
}
|
||||
const started = Promise.withResolvers<AbortSignal>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
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<Array<{ header: SessionHeader; path: string }>>
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -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<unknown>
|
||||
}
|
||||
|
||||
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<string> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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<StoredPrefix<number> | undefined> {
|
||||
return this.readPrefix(id)
|
||||
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | 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<StoredPrefix<number> | undefined> {
|
||||
private async readPrefix(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | 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,18 +254,24 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
|
||||
}
|
||||
|
||||
/** List all materialized sessions' metadata (every row is a materialized session). */
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
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)
|
||||
}
|
||||
|
||||
/** List metadata with a source-qualified monotonic revision per session. */
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
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(
|
||||
|
||||
@@ -580,6 +580,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<void> }
|
||||
const originalReady = internals.ready
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
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(10)
|
||||
})
|
||||
|
||||
@@ -12,9 +12,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
|
||||
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
|
||||
| `append(id, events): Promise<void>` | 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<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
|
||||
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | 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. |
|
||||
| `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<SessionHeader[]>` | 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(signal?): Promise<SessionPersistenceSnapshot[]>` | 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,17 +33,17 @@ 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<TornMarker>` hooks (the only seam between the coordinator and storage):
|
||||
|
||||
| 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).
|
||||
|
||||
@@ -40,8 +40,10 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
* `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<StoredPrefix<TornMarker> | undefined>
|
||||
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
|
||||
|
||||
/**
|
||||
* Durably append a CONTIGUOUS batch, lazily materializing the session first
|
||||
@@ -60,8 +62,11 @@ export interface PersistenceBackend<TornMarker = unknown> {
|
||||
*/
|
||||
commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
|
||||
|
||||
/** List all stored (materialized) sessions' metadata. */
|
||||
list(): Promise<SessionHeader[]>
|
||||
/**
|
||||
* List all stored (materialized) sessions' metadata.
|
||||
* @param signal - optional cancellation for backend listing work.
|
||||
*/
|
||||
list(signal?: AbortSignal): Promise<SessionHeader[]>
|
||||
|
||||
/**
|
||||
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
|
||||
@@ -270,14 +275,26 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
* 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<TornMarker> | 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)
|
||||
@@ -334,9 +351,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
* public methods must NOT call each other (deadlock); they call the unserialized
|
||||
* `*Core` helpers instead.
|
||||
*/
|
||||
private serialize<T>(id: SessionId, op: () => Promise<T> | T): Promise<T> {
|
||||
private serialize<T>(
|
||||
id: SessionId,
|
||||
op: () => Promise<T> | T,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const prior = this.chains.get(id) ?? Promise.resolve()
|
||||
const next = prior.then(op, op)
|
||||
let started = false
|
||||
const run = (): Promise<T> | 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)
|
||||
@@ -346,7 +373,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
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. */
|
||||
@@ -618,3 +645,50 @@ export class PersistenceCoordinator<TornMarker = unknown> {
|
||||
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<T>(
|
||||
operation: Promise<T>,
|
||||
signal: AbortSignal,
|
||||
started: () => boolean,
|
||||
): Promise<T> {
|
||||
return new Promise<T>((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)
|
||||
}
|
||||
|
||||
@@ -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<SessionHeader[]>
|
||||
abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
|
||||
|
||||
/**
|
||||
* List materialized sessions with cheap per-log change tokens.
|
||||
@@ -120,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<SessionPersistenceSnapshot[]>
|
||||
abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
|
||||
}
|
||||
|
||||
export default SessionPersistence
|
||||
|
||||
@@ -238,6 +238,23 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
|
||||
const { persistence, dispose } = await make()
|
||||
try {
|
||||
const reason = new Error('persistence observation cancelled')
|
||||
const controller = new AbortController()
|
||||
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
|
||||
controller.abort(reason)
|
||||
|
||||
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
|
||||
await expect(persistence.listSnapshots(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 {
|
||||
|
||||
@@ -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,11 +132,13 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
|
||||
if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
|
||||
}
|
||||
|
||||
async list(): Promise<SessionHeader[]> {
|
||||
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
signal?.throwIfAborted()
|
||||
return [...this.store.values()].map(e => structuredClone(e.meta))
|
||||
}
|
||||
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
signal?.throwIfAborted()
|
||||
return [...this.store.values()].map(entry => ({
|
||||
header: structuredClone(entry.meta),
|
||||
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
|
||||
@@ -153,10 +155,10 @@ class ControlledBackend implements PersistenceBackend<never> {
|
||||
loadAttempts = 0
|
||||
repairAttempts = 0
|
||||
beforeAppend?: (attempt: number) => Promise<void>
|
||||
beforeLoadStored?: (attempt: number) => Promise<void>
|
||||
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
|
||||
|
||||
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
|
||||
await this.beforeLoadStored?.(++this.loadAttempts)
|
||||
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | 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 +350,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<boolean>()
|
||||
backend.beforeLoadStored = async (attempt) => {
|
||||
if (attempt === 1) await loadGate.promise
|
||||
}
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
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<boolean>()
|
||||
let cleanupComplete = false
|
||||
backend.beforeLoadStored = async (_attempt, signal) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
signal?.addEventListener('abort', () => {
|
||||
void cleanupGate.promise.then(() => {
|
||||
cleanupComplete = true
|
||||
resolve()
|
||||
})
|
||||
}, { once: true })
|
||||
})
|
||||
throw new Error('backend cancellation after cleanup')
|
||||
}
|
||||
let coordinator!: PersistenceCoordinator<never>
|
||||
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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -28,12 +28,13 @@ 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -25,6 +26,7 @@ import type {
|
||||
Config as SessionQueryConfig,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchPage,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
@@ -86,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 {
|
||||
@@ -95,6 +99,7 @@ interface ResolvedConfig {
|
||||
maxLimit: number
|
||||
snippetChars: number
|
||||
readWindowMax: number
|
||||
persistedInspectConcurrency: number
|
||||
}
|
||||
|
||||
interface ObservedSession {
|
||||
@@ -133,7 +138,7 @@ interface IndexedLiveRow {
|
||||
generation: number
|
||||
}
|
||||
|
||||
interface SearchRow {
|
||||
interface SessionHeaderRow {
|
||||
session_id: string
|
||||
version: number
|
||||
created_at: number
|
||||
@@ -141,6 +146,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
|
||||
@@ -172,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. */
|
||||
@@ -247,27 +260,30 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
override async searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
): Promise<SessionEventSearchPage> {
|
||||
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),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -336,6 +352,7 @@ export class SessionQuerySqlite extends SessionQueryService {
|
||||
}
|
||||
|
||||
private async _reconcile(signal: AbortSignal | undefined): Promise<PersistenceBinding> {
|
||||
assertNotAborted(signal)
|
||||
const db = this._requireDb()
|
||||
const persistedRows = db.prepare(
|
||||
'SELECT id, revision, generation FROM persisted_sessions',
|
||||
@@ -436,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
|
||||
@@ -445,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) {
|
||||
@@ -643,17 +664,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 +872,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,
|
||||
@@ -914,6 +951,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')
|
||||
@@ -924,6 +963,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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -68,11 +69,16 @@ class TestPersistence extends SessionPersistence {
|
||||
static nextRevision = 0
|
||||
static loads = new Map<SessionIdType, number>()
|
||||
static inspections = new Map<SessionIdType, number>()
|
||||
static inspectSignals: Array<AbortSignal | undefined> = []
|
||||
static snapshotSignals: Array<AbortSignal | undefined> = []
|
||||
static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined
|
||||
static inspectEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void | Promise<void>) | undefined
|
||||
static inspectEffect: ((
|
||||
entry: { meta: SessionHeader; events: SessionEvent[] },
|
||||
signal?: AbortSignal,
|
||||
) => void | Promise<void>) | undefined
|
||||
static listGate: Promise<void> | undefined
|
||||
static listStarted: (() => void) | undefined
|
||||
static snapshotEffect: (() => void | Promise<void>) | undefined
|
||||
static snapshotEffect: ((signal?: AbortSignal) => void | Promise<void>) | undefined
|
||||
static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined
|
||||
static failure: unknown
|
||||
|
||||
@@ -85,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)
|
||||
@@ -127,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)
|
||||
}
|
||||
@@ -145,7 +154,8 @@ class TestPersistence extends SessionPersistence {
|
||||
}
|
||||
|
||||
|
||||
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
|
||||
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
|
||||
TestPersistence.snapshotSignals.push(signal)
|
||||
TestPersistence.listStarted?.()
|
||||
await TestPersistence.listGate
|
||||
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
|
||||
@@ -154,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
|
||||
}
|
||||
}
|
||||
@@ -167,6 +177,29 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli
|
||||
}
|
||||
|
||||
describe('SQLite session search', () => {
|
||||
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'), {
|
||||
@@ -179,7 +212,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 }] })
|
||||
})
|
||||
@@ -483,6 +519,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' },
|
||||
]) {
|
||||
@@ -1200,6 +1238,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<AbortSignal>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
TestPersistence.snapshotEffect = async (signal) => {
|
||||
TestPersistence.snapshotEffect = undefined
|
||||
if (signal === undefined) throw new Error('expected reconciliation signal')
|
||||
started.resolve(signal)
|
||||
await new Promise<void>((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<undefined>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
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<AbortSignal>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
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()
|
||||
@@ -1253,8 +1452,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 })
|
||||
@@ -1262,6 +1468,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<void>
|
||||
_ensureReady(signal: AbortSignal): Promise<void>
|
||||
}
|
||||
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<void>
|
||||
_ensureReady(signal: AbortSignal | undefined): Promise<void>
|
||||
}
|
||||
const readiness = Promise.withResolvers<undefined>()
|
||||
internals._ready = readiness.promise
|
||||
const readyWaitStarted = Promise.withResolvers<undefined>()
|
||||
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
|
||||
@@ -1327,7 +1584,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()
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
- `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 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. `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
|
||||
|
||||
@@ -25,7 +25,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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -15,12 +15,28 @@ 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<Value> =
|
||||
| { sessionId: SessionId; status: 'fulfilled'; value: Value }
|
||||
| { sessionId: SessionId; status: 'rejected'; reason: unknown }
|
||||
|
||||
/** 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
|
||||
@@ -36,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<SessionRecord[]> {
|
||||
async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
||||
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<SessionId, SessionRecord>()
|
||||
for (const header of persisted) {
|
||||
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
|
||||
@@ -63,39 +82,181 @@ 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<LogicalSession> {
|
||||
async load(sessionId: SessionId, signal?: AbortSignal): Promise<LogicalSession> {
|
||||
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)
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['inspect']>>
|
||||
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, 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<Value>(
|
||||
sessionIds: readonly SessionId[],
|
||||
project: (source: LogicalSessionSource) => Value,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LogicalProjectionResult<Value>[]> {
|
||||
const ids = [...new Set(sessionIds)]
|
||||
signal?.throwIfAborted()
|
||||
const resolved = new Map<SessionId, LogicalProjectionResult<Value>>()
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
for (;;) {
|
||||
signal?.throwIfAborted()
|
||||
const index = cursor
|
||||
if (index >= unresolved.length) return
|
||||
cursor += 1
|
||||
await resolvePersisted(unresolved[index] as SessionId)
|
||||
}
|
||||
}
|
||||
const workerCount = Math.min(this._persistedInspectConcurrency, 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<SessionHeader[]> {
|
||||
function projectSource<Value>(
|
||||
sessionId: SessionId,
|
||||
source: LogicalSessionSource,
|
||||
project: (source: LogicalSessionSource) => Value,
|
||||
signal?: AbortSignal,
|
||||
): LogicalProjectionResult<Value> {
|
||||
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<Value>(
|
||||
ids: readonly SessionId[],
|
||||
resolved: ReadonlyMap<SessionId, LogicalProjectionResult<Value>>,
|
||||
): LogicalProjectionResult<Value>[] {
|
||||
return ids.map(sessionId => resolved.get(sessionId) as LogicalProjectionResult<Value>)
|
||||
}
|
||||
|
||||
async function listPersisted(
|
||||
persistence: SessionPersistence,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionHeader[]> {
|
||||
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',
|
||||
@@ -104,6 +265,23 @@ async function listPersisted(persistence: SessionPersistence): Promise<SessionHe
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPersisted(
|
||||
persistence: SessionPersistence,
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Awaited<ReturnType<SessionPersistence['inspect']>>> {
|
||||
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),
|
||||
|
||||
@@ -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,
|
||||
@@ -27,8 +27,11 @@ import type {
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
SessionSurfaceSnapshot,
|
||||
SessionTitleObservation,
|
||||
SessionTitleObservationResult,
|
||||
} from './types.ts'
|
||||
import {
|
||||
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
|
||||
SESSION_QUERY_READ_WINDOW_MAX,
|
||||
SessionQueryError,
|
||||
type Config,
|
||||
@@ -46,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 {
|
||||
@@ -86,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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,19 +119,20 @@ 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<SessionSearchPage<SessionEventSearchHit>>
|
||||
): Promise<SessionEventSearchPage>
|
||||
|
||||
/**
|
||||
* 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<SessionRecord[]> {
|
||||
return this._corpus.listSessions()
|
||||
listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
|
||||
return this._corpus.listSessions(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -137,21 +153,65 @@ 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<SessionRecord[]> {
|
||||
async filterSessions(
|
||||
filters: readonly SessionResultFilter[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionRecord[]> {
|
||||
const ownedFilters = materializeSessionResultFilters(filters)
|
||||
return this._filterSessions(ownedFilters)
|
||||
return this._filterSessions(ownedFilters, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SessionTitleSnapshot | undefined> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
return foldSessionTitle(loaded.events)
|
||||
async readTitle(
|
||||
sessionId: SessionId,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleSnapshot | undefined> {
|
||||
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,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionTitleObservation> {
|
||||
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<SessionTitleObservationResult[]> {
|
||||
return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => {
|
||||
const title = foldSessionTitle(source.events)
|
||||
return {
|
||||
session: structuredClone(source.header),
|
||||
...title === undefined ? {} : { title },
|
||||
}
|
||||
}, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,8 +238,11 @@ export abstract class SessionQueryService extends Service {
|
||||
return this._filterEvents(sessionId, ownedFilters)
|
||||
}
|
||||
|
||||
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(), filters)
|
||||
private async _filterSessions(
|
||||
filters: readonly SessionResultFilter[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(signal), filters)
|
||||
}
|
||||
|
||||
private async _filterEvents(
|
||||
@@ -209,36 +272,44 @@ 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<SessionLineageTrace> {
|
||||
const records = await this._corpus.listSessions()
|
||||
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> {
|
||||
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.
|
||||
* @returns direct links plus the target's positional replacement chain.
|
||||
* @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<SessionEventTrace> {
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
return tracing.traceEvent(request.sessionId, loaded.events, request.seq)
|
||||
async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation> {
|
||||
const loaded = await this._corpus.load(request.sessionId, signal)
|
||||
signal?.throwIfAborted()
|
||||
return {
|
||||
session: loaded.header,
|
||||
...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SessionEventWindow> {
|
||||
async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow> {
|
||||
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(
|
||||
@@ -246,8 +317,10 @@ export abstract class SessionQueryService extends Service {
|
||||
seq: number,
|
||||
before: number,
|
||||
after: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SessionEventWindow> {
|
||||
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(
|
||||
|
||||
@@ -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'
|
||||
@@ -116,6 +117,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. */
|
||||
@@ -142,6 +149,33 @@ 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
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
@@ -192,6 +226,12 @@ export interface SessionSearchPage<T> {
|
||||
nextCursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Event-search results bound to the indexed target-session observation. */
|
||||
export interface SessionEventSearchPage extends SessionSearchPage<SessionEventSearchHit> {
|
||||
/** 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. */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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'
|
||||
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'
|
||||
@@ -27,16 +28,31 @@ function eventLog(text = 'hello'): SessionEvent[] {
|
||||
class TestPersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listFailure: unknown
|
||||
static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | 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<AbortSignal | undefined> = []
|
||||
static inspectSignals: Array<AbortSignal | undefined> = []
|
||||
|
||||
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 +75,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 +93,10 @@ class TestPersistence extends SessionPersistence {
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
list(signal?: AbortSignal): Promise<SessionHeader[]> {
|
||||
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?.()
|
||||
@@ -104,6 +131,287 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
interface CancellableExactRead {
|
||||
readonly name: 'traceSession' | 'traceEvent' | 'readEvent'
|
||||
readonly inspects: boolean
|
||||
readonly run: (
|
||||
ctx: Context,
|
||||
sessionId: SessionIdType,
|
||||
signal: AbortSignal,
|
||||
) => Promise<unknown>
|
||||
}
|
||||
|
||||
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()
|
||||
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<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected persistence listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((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<undefined>()
|
||||
const listing = Promise.withResolvers<SessionHeader[]>()
|
||||
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.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<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
let active = false
|
||||
TestPersistence.listOverride = async (signal) => {
|
||||
if (signal === undefined) throw new Error('expected exact-read listing signal')
|
||||
active = true
|
||||
const aborted = new Promise<void>((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<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
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<undefined>()
|
||||
const abortObserved = Promise.withResolvers<undefined>()
|
||||
const cleanup = Promise.withResolvers<undefined>()
|
||||
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<void>((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)
|
||||
@@ -192,6 +500,341 @@ 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<void>(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(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))
|
||||
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<SessionIdType, () => 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<void>((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 persistedInspectConcurrency = 2
|
||||
const ctx = await liveContext({ persistedInspectConcurrency })
|
||||
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(persistedInspectConcurrency)
|
||||
})
|
||||
controller.abort(reason)
|
||||
await vi.waitFor(() => { expect(abortsObserved).toBe(persistedInspectConcurrency) })
|
||||
|
||||
expect(batchSettled).toBe(false)
|
||||
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(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 () => {
|
||||
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<void>((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 } })
|
||||
@@ -408,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'))
|
||||
})
|
||||
@@ -459,10 +1108,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 () => {
|
||||
|
||||
@@ -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<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
): Promise<SessionEventSearchPage> {
|
||||
return {
|
||||
session: (await this.readSurface(request.sessionId)).session,
|
||||
items: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
packages/session-query/tool-session-query/README.md
Normal file
74
packages/session-query/tool-session-query/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# @deepseek-ai/dsh-tool-session-query
|
||||
|
||||
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
|
||||
|
||||
| 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. 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. 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.
|
||||
|
||||
## 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.
|
||||
58
packages/session-query/tool-session-query/package.json
Normal file
58
packages/session-query/tool-session-query/package.json
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"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-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
137
packages/session-query/tool-session-query/src/index.ts
Normal file
137
packages/session-query/tool-session-query/src/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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 { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
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'
|
||||
|
||||
/** 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<Config> = 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
|
||||
}
|
||||
|
||||
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: toolInput.sessionSearchParameters,
|
||||
output: TEXT_OUTPUT,
|
||||
timeoutMs: resolved.searchTimeoutMs,
|
||||
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: toolInput.eventSearchParameters,
|
||||
output: TEXT_OUTPUT,
|
||||
timeoutMs: resolved.searchTimeoutMs,
|
||||
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: toolInput.targetSessionParameter,
|
||||
output: TEXT_OUTPUT,
|
||||
isConcurrencySafe: () => true,
|
||||
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: {
|
||||
...toolInput.targetSessionParameter,
|
||||
seq: { type: 'integer', required: true, description: 'Target event sequence number.' },
|
||||
},
|
||||
output: TEXT_OUTPUT,
|
||||
isConcurrencySafe: () => true,
|
||||
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: {
|
||||
...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) => operations.executeEventRead(ctx, args, exec),
|
||||
presentCall: args => presentation.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 }
|
||||
}
|
||||
307
packages/session-query/tool-session-query/src/input.ts
Normal file
307
packages/session-query/tool-session-query/src/input.ts
Normal file
@@ -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,
|
||||
}
|
||||
30
packages/session-query/tool-session-query/src/invariant.ts
Normal file
30
packages/session-query/tool-session-query/src/invariant.ts
Normal file
@@ -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 */
|
||||
281
packages/session-query/tool-session-query/src/operations.ts
Normal file
281
packages/session-query/tool-session-query/src/operations.ts
Normal file
@@ -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<typeof toolInput.buildSessionFilters>[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<T> {
|
||||
readonly items: T[]
|
||||
readonly capped: boolean
|
||||
}
|
||||
|
||||
async function executeSessionSearch(
|
||||
ctx: Context,
|
||||
args: SessionSearchArgs,
|
||||
exec: ToolRunContext,
|
||||
maxResults: number,
|
||||
): Promise<string> {
|
||||
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<SessionId>()
|
||||
: await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
|
||||
const parentValues: Array<SessionId | null> = 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<string> {
|
||||
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<SessionEventSearchPage> => {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
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<T>(
|
||||
maxResults: number,
|
||||
signal: AbortSignal,
|
||||
request: (cursor?: SessionSearchCursor) => Promise<{
|
||||
readonly items: readonly T[]
|
||||
readonly nextCursor?: SessionSearchCursor
|
||||
}>,
|
||||
accept: (item: T) => boolean,
|
||||
): Promise<SearchCollection<T>> {
|
||||
const items: T[] = []
|
||||
const seen = new Set<SessionSearchCursor>()
|
||||
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,
|
||||
}
|
||||
255
packages/session-query/tool-session-query/src/presentation.ts
Normal file
255
packages/session-query/tool-session-query/src/presentation.ts
Normal file
@@ -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<ReturnType<typeof workspaceAccess.readTitle>>
|
||||
type CompleteTitleMap = Awaited<ReturnType<typeof workspaceAccess.readTitles>>
|
||||
type AuthorizedDescendants = ReturnType<typeof workspaceAccess.authorizeDescendants>
|
||||
|
||||
interface SearchCollection<T> {
|
||||
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<SessionSearchHit>,
|
||||
titles: CompleteTitleMap,
|
||||
authorizedParents: ReadonlySet<SessionId>,
|
||||
): 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<SessionEventSearchHit>,
|
||||
): 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,
|
||||
}
|
||||
@@ -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<SessionQueryErrorCode, ModelSafeServiceFailure>
|
||||
|
||||
function unauthorizedTarget(): HarnessError {
|
||||
return new HarnessError(
|
||||
'session target is outside the caller workspace',
|
||||
'SESSION_QUERY_TOOL_UNAUTHORIZED',
|
||||
)
|
||||
}
|
||||
|
||||
async function call<Value>(
|
||||
ctx: Context,
|
||||
signal: AbortSignal,
|
||||
operation: string,
|
||||
invoke: () => Promise<Value>,
|
||||
): Promise<Value> {
|
||||
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<Error>()
|
||||
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,
|
||||
}
|
||||
@@ -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<SessionIdValue, TitleView> {
|
||||
get(id: SessionIdValue): TitleView
|
||||
}
|
||||
|
||||
interface AuthorizedDescendant {
|
||||
readonly record: SessionRecord
|
||||
readonly descendants: Array<AuthorizedDescendant | null>
|
||||
}
|
||||
|
||||
interface DescendantProjectionFrame {
|
||||
readonly node: SessionLineageNode
|
||||
readonly target: Array<AuthorizedDescendant | null>
|
||||
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<void> {
|
||||
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<ReadonlySet<SessionIdValue>> {
|
||||
const unique = [...new Set(ids)]
|
||||
const authorized = new Set<SessionIdValue>()
|
||||
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<CompleteTitleMap> {
|
||||
const result = new Map<SessionIdValue, TitleView>()
|
||||
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<TitleView> {
|
||||
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<AuthorizedDescendant | null> {
|
||||
const result: Array<AuthorizedDescendant | null> = []
|
||||
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<DescendantVisit> {
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
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')
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
40
packages/session-query/tool-session-query/tsconfig.json
Normal file
40
packages/session-query/tool-session-query/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,11 +11,17 @@ const CWD = '{{cwd}}'
|
||||
const SYSTEM = '{{system}}'
|
||||
const TOOLS = '{{tools}}'
|
||||
const MESSAGE_PREFIX = '{{messagePrefix}}'
|
||||
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>)([^<]*)(<\/path>)/g
|
||||
const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\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_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
|
||||
@@ -77,6 +83,16 @@ 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 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)
|
||||
out = out.replace(UUID_RE, SESSION_ID)
|
||||
return out
|
||||
|
||||
@@ -123,6 +123,56 @@ Additional instructions from: nested\AGENTS.md`,
|
||||
expect(out).not.toContain('"id"')
|
||||
})
|
||||
|
||||
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',
|
||||
params: {
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
content: [{
|
||||
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 "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.)',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
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')
|
||||
})
|
||||
|
||||
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```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)',
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
})
|
||||
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)', () => {
|
||||
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
|
||||
expect(() => normalizeStdout(raw, ctx)).toThrow()
|
||||
|
||||
@@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService {
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
..._args: Parameters<SessionQueryService['searchEvents']>
|
||||
...args: Parameters<SessionQueryService['searchEvents']>
|
||||
): ReturnType<SessionQueryService['searchEvents']> {
|
||||
return Promise.resolve({ items: [] })
|
||||
return this.readSurface(args[0].sessionId).then(surface => ({
|
||||
session: surface.session,
|
||||
items: [],
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@@ -505,6 +505,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
|
||||
@@ -1561,6 +1564,12 @@ importers:
|
||||
'@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/session-query
|
||||
'@deepseek-ai/dsh-session-query-sqlite':
|
||||
specifier: workspace:^
|
||||
version: link:../../session-query/session-query-sqlite
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
@@ -3005,6 +3014,55 @@ 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-timeout-policy':
|
||||
specifier: workspace:^
|
||||
version: link:../../timeout/timeout-policy
|
||||
'@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:
|
||||
|
||||
@@ -127,8 +127,10 @@ export const LINK_MAP: Record<string, string> = {
|
||||
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',
|
||||
@@ -138,6 +140,8 @@ export const LINK_MAP: Record<string, string> = {
|
||||
SessionSearchHit: 'session-query.md',
|
||||
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',
|
||||
|
||||
@@ -166,8 +166,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',
|
||||
|
||||
@@ -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. 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',
|
||||
dir: 'tool-subagent',
|
||||
|
||||
@@ -419,6 +419,16 @@
|
||||
"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": "SessionTitleObservationResult",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.md",
|
||||
"symbol": "SessionEventRecord",
|
||||
@@ -459,6 +469,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-title.md",
|
||||
"symbol": "SessionTitleProviderId",
|
||||
@@ -1219,6 +1234,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",
|
||||
@@ -1562,6 +1582,16 @@
|
||||
"symbol": "SessionSurfaceSnapshot",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionTitleObservation",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionTitleObservationResult",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventRecord",
|
||||
@@ -1602,6 +1632,11 @@
|
||||
"symbol": "SessionSearchPage",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchPage",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventSearchHit",
|
||||
@@ -1647,6 +1682,11 @@
|
||||
"symbol": "SessionEventTrace",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session-query.zh.md",
|
||||
"symbol": "SessionEventTraceObservation",
|
||||
"source": "packages/session-query/session-query/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/tools.zh.md",
|
||||
"symbol": "ToolOutputDefinition",
|
||||
|
||||
@@ -45,6 +45,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/storage/storage" },
|
||||
{ "path": "./packages/storage/storage-json" },
|
||||
{ "path": "./packages/storage/storage-sqlite" },
|
||||
|
||||
Reference in New Issue
Block a user