diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 8169bc5977..361145cb02 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 521b62fdc668f5c2e208118640be5cec99561a5c -2026-07-24-model-facing-session-query-tools.zh.md: 9be772b33e0e14f503ab2c762a831493381266fd +2026-07-24-model-facing-session-query-tools.md: 68169d8af7176ee1725a3c58bf97530f56a0765b +2026-07-24-model-facing-session-query-tools.zh.md: dfeae26a548b5e498e76fce259f7610106de7ec2 diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 521b62fdc6..68169d8af7 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -18,7 +18,7 @@ Model-facing filters use flat snake-case fields. Timestamps are timezone-qualifi ## Workspace authority -Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its persisted `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter, direct reads and traces authorize before loading the target, and lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. +Every executor derives its caller from immutable `ToolExecution.exec.agent` identity and never accepts a model-supplied workspace. A target is authorized only when its observed `cwd` exactly equals the caller session's `cwd`. Cross-session search always adds that workspace filter. Direct operations preflight the target and then validate the header returned from the same service observation as every event-search page, event trace, event read, lineage target, or folded title before rendering its payload. This prevents a live or persisted target replacement between the check and use from crossing the workspace boundary. Lineage rendering stops at an unauthorized ancestor or descendant subtree without revealing the hidden session id. A caller whose session has no `cwd` can inspect only its own session; missing agent identity fails closed. The search tools expose prior work rather than the operation that is performing the search. `session_search` omits the caller's session. When `session_event_search` targets the caller's session, it intersects the requested sequence range with the event immediately before the current `step/start`, excluding the current assistant message and tool call as well as the query arguments indexed from that call. @@ -28,7 +28,7 @@ Neither search tool exposes a cursor, offset, page size, or model-controlled res Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. -Session-level results include the latest folded title when available. Absence is rendered as untitled; a title read failure preserves the base result, renders an unavailable marker, and logs the underlying error. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. +Session-level results include the latest folded title when available. Absence is rendered as untitled; an operational title read failure preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. ## Host composition @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, count caps, cancellation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 9be772b33e..dfeae26a54 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -18,7 +18,7 @@ Status: implemented ## 工作区权限 -每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标持久化的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件;直接读取与追踪在加载目标前完成授权;谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 +每个执行器都从不可变的 `ToolExecution.exec.agent` 身份推导调用者,绝不接受模型提供的工作区。只有当目标观测中的 `cwd` 与调用者会话的 `cwd` 完全相同时,目标才获授权。跨会话搜索始终附加该工作区过滤条件。直接操作先预检目标,然后在渲染负载前,校验与每一页事件搜索结果、事件追踪、事件读取、谱系目标或折叠标题来自同一服务观测的会话头。这样,即使实时或持久化目标在检查与使用之间被替换,也无法跨越工作区边界。谱系渲染在遇到未授权的祖先或后代子树时停止,且不泄露被隐藏的会话 id。调用者会话没有 `cwd` 时只能检查自身会话;缺少 agent 身份时按失败关闭处理。 搜索工具公开的是既往工作,而不是正在执行搜索的操作本身。`session_search` 排除调用者会话。`session_event_search` 以调用者会话为目标时,会把请求的序号范围与当前 `step/start` 之前的最后一个事件取交集,从而排除当前 assistant 消息、工具调用,以及从该次调用中建立索引的查询参数。 @@ -28,7 +28,7 @@ Status: implemented 追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 -会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取失败时保留基础结果,渲染不可用标记,并记录底层错误。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 +会话级结果在可用时包含最新折叠标题。没有标题时渲染为未命名;标题读取发生操作性失败时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 ## 宿主组合 @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及精确事件读取的 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、数量上限、取消、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d76443579a..3fb5238ff0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1027,7 +1027,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:74`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:75`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` @@ -1437,7 +1437,7 @@ export interface Config { } ``` -Source: [`packages/session-query/tool-session-query/src/index.ts:50`](../packages/session-query/tool-session-query/src/index.ts) +Source: [`packages/session-query/tool-session-query/src/index.ts:51`](../packages/session-query/tool-session-query/src/index.ts) ## `@deepseek-ai/dsh-tool-skill` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index c5d6d88d5c..a01b275a30 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -986,9 +986,9 @@ abstract searchSessions( request: SessionSearchRequest, exec?: SessionSearchExec * Search events within one live-preferred logical session. * @param request - target session, query text, filters, page size, and cursor. * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. + * @returns matching event hits and their target header from one indexed generation. */ -abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise> +abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise /** * List the complete logical corpus using live-preferred records. @@ -1010,6 +1010,13 @@ async filterSessions(filters: readonly SessionResultFilter[]): Promise +/** + * Fold the latest title and return its source header from one corpus observation. + * @param sessionId - live or persisted session id to read. + * @returns cloned source header and optional latest title snapshot. + */ +async readTitleSnapshot(sessionId: SessionId): Promise + /** * List lightweight raw-log event records for one logical session. * @param sessionId - live-preferred session id to read. @@ -1044,10 +1051,10 @@ async traceSession(sessionId: SessionId): Promise /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. - * @returns direct links plus the target's positional replacement chain. + * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ -async traceEvent(request: SessionEventTraceRequest): Promise +async traceEvent(request: SessionEventTraceRequest): Promise /** * Read one full event plus a bounded raw-log context window. @@ -1057,9 +1064,9 @@ async traceEvent(request: SessionEventTraceRequest): Promise async readEvent(request: SessionEventReadRequest): Promise ``` -Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchHit](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTrace](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) +Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) -Source: [`packages/session-query/session-query/src/index.ts:73`](../../packages/session-query/session-query/src/index.ts) +Source: [`packages/session-query/session-query/src/index.ts:74`](../../packages/session-query/session-query/src/index.ts) ## `ctx.sessionReferences` — `SessionReferenceService` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 0fe1596aaf..2cd7650dbc 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -39,6 +39,18 @@ interface SessionSurfaceSnapshot { } ``` +`SessionTitleObservation` applies the same atomic-observation rule to title folding, so an authorization consumer can validate the source header that supplied the title. + +```ts type-equiv +/** Latest folded title bound to the same session-header observation. */ +interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} +``` + ```ts type-equiv /** Lightweight metadata for one event within a logical session. */ interface SessionEventRecord { @@ -146,6 +158,16 @@ interface SessionSearchPage { } ``` +Unlike grouped cross-session hits, a within-session search must also expose its observed target header even when the page contains no hits. + +```ts type-equiv +/** Event-search results bound to the indexed target-session observation. */ +interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} +``` + ```ts type-equiv /** One event full-text search hit with a bounded plain-text excerpt. */ interface SessionEventSearchHit extends SessionEventRecord { @@ -267,6 +289,14 @@ interface SessionEventTrace { } ``` +```ts type-equiv +/** Event relationships bound to the same session-header observation. */ +interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} +``` + ## Errors The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl index e0629f89a2..0f4bee73ca 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -3,7 +3,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"available_commands_update","availableCommands":[{"name":"goal","description":"set or view the goal for a long-running task","input":{"hint":"[|clear|edit |pause|resume]"}},{"name":"plan","description":"Enter or leave plan mode","input":{"hint":"[off|message]"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"session_info_update","title":"Read request event 4 with","updatedAt":"{{updatedAt}}"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"Read event 4","kind":"read","status":"in_progress","rawInput":{"seq":4}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted 39431 bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 4 with\nTarget event seq 4:\n```json\n{\n \"type\": \"request/header\",\n \"seq\": 4,\n \"time\": {{eventTime}},\n \"data\": {\n \"header\": {\n \"config\": {\n \"provider\": \"deepseek\",\n \"model\": \"deepseek-v4-flash\"\n },\n rmissions: one sentence for the user explaining why this exact file operation needs the wider access.\"\n }\n },\n \"required\": [\n \"file_path\",\n \"content\"\n ]\n }\n }\n ]\n },\n \"reason\": \"initial\"\n }\n}\n```\n\n(Omitted {{eventOmittedBytes}} bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","kind":"execute","status":"in_progress","rawInput":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","content":[{"type":"content","content":{"type":"text","text":"Verify complete session query spill"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"```console\nSPILL_CANONICAL_OK\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index 2470ae8d93..4bcc95af16 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -23,9 +23,12 @@ class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ffc1670f4e..caab8c0076 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -491,8 +491,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */', }, { - signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise>', - jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */', + signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise', + jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits and their target header from one indexed generation.\n */', }, { signature: 'listSessions(): Promise', @@ -506,6 +506,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'async readTitle(sessionId: SessionId): Promise', jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */', }, + { + signature: 'async readTitleSnapshot(sessionId: SessionId): Promise', + jsDoc: '/**\n * Fold the latest title and return its source header from one corpus observation.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned source header and optional latest title snapshot.\n */', + }, { signature: 'async listEvents(sessionId: SessionId): Promise', jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */', @@ -523,8 +527,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { - signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', - jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns direct links plus the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', + signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', + jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', }, { signature: 'async readEvent(request: SessionEventReadRequest): Promise', @@ -1761,6 +1765,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventSearchHit', declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}', }, + { + name: 'SessionEventSearchPage', + declaration: 'export interface SessionEventSearchPage extends SessionSearchPage {\n session: SessionHeader;\n}', + }, { name: 'SessionEventSearchRequest', declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}', @@ -1773,6 +1781,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionEventTrace', declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}', }, + { + name: 'SessionEventTraceObservation', + declaration: 'export interface SessionEventTraceObservation extends SessionEventTrace {\n session: SessionHeader;\n}', + }, { name: 'SessionEventTraceRequest', declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}', @@ -1873,6 +1885,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionTitleModelProvenance', declaration: 'export interface SessionTitleModelProvenance {\n readonly provider: string;\n readonly model: string;\n}', }, + { + name: 'SessionTitleObservation', + declaration: 'export interface SessionTitleObservation {\n session: SessionHeader;\n title?: SessionTitleSnapshot;\n}', + }, { name: 'SessionTitleProvider', declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise;\n}', diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 5e795d4d2c..b3ff8feb07 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -25,6 +25,7 @@ import type { Config as SessionQueryConfig, SessionEventSearchDocument, SessionEventSearchHit, + SessionEventSearchPage, SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, @@ -133,7 +134,7 @@ interface IndexedLiveRow { generation: number } -interface SearchRow { +interface SessionHeaderRow { session_id: string version: number created_at: number @@ -141,6 +142,9 @@ interface SearchRow { parent_session: string | null seed_length: number | null delegation_depth: number | null +} + +interface SearchRow extends SessionHeaderRow { live: number persisted: number seq: number @@ -247,27 +251,30 @@ export class SessionQuerySqlite extends SessionQueryService { override async searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> { + ): Promise { const normalized = normalizeEventRequest(request, this.config) const signal = exec?.signal return this._serialized(signal, async () => { await this._ensureReady(signal) const persistenceBinding = await this._reconcile(signal) assertNotAborted(signal) - const generation = this._targetGeneration(normalized.sessionId, persistenceBinding) + const target = this._targetObservation(normalized.sessionId, persistenceBinding) const fingerprint = requestFingerprint(normalized) const offset = normalized.cursor === undefined ? 0 - : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation) + : decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, target.generation) const rows = this._queryEvents(normalized, offset, persistenceBinding) - return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ - version: 1, - instance: this._instance, - scope: 'events', - fingerprint, - generation, - offset: cursorOffset, - }), offset) + return { + session: target.header, + ...page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({ + version: 1, + instance: this._instance, + scope: 'events', + fingerprint, + generation: target.generation, + offset: cursorOffset, + }), offset), + } }) } @@ -643,17 +650,33 @@ export class SessionQuerySqlite extends SessionQueryService { `).all(...bindings) as unknown as SearchRow[] } - private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string { + private _targetObservation( + sessionId: SessionId, + persistenceBinding: PersistenceBinding, + ): { header: SessionHeader; generation: string } { const db = this._requireDb() const live = db.prepare( - 'SELECT generation FROM temp.live_sessions WHERE id = ?', - ).get(sessionId) as { generation: number } | undefined - if (live !== undefined) return `live:${live.generation}` + `SELECT + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + FROM temp.live_sessions + WHERE id = ?`, + ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined + if (live !== undefined) { + return { header: rowHeader(live), generation: `live:${live.generation}` } + } if (persistenceBinding.service !== undefined) { const persisted = db.prepare( - 'SELECT generation FROM persisted_sessions WHERE id = ?', - ).get(sessionId) as { generation: number } | undefined - if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}` + `SELECT + id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation + FROM persisted_sessions + WHERE id = ?`, + ).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined + if (persisted !== undefined) { + return { + header: rowHeader(persisted), + generation: `persisted:${this._persistenceEpoch}:${persisted.generation}`, + } + } } throw new SessionQueryError( `session "${sessionId}" not found`, @@ -835,7 +858,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean { && (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0) } -function rowHeader(row: SearchRow): SessionHeader { +function rowHeader(row: SessionHeaderRow): SessionHeader { return { version: row.version, id: row.session_id as SessionId, diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 1923c6f3eb..71892159d5 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -179,7 +179,10 @@ describe('SQLite session search', () => { ) await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' })) - .resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] }) + .resolves.toMatchObject({ + session: { ...session.header, seedLength: 1 }, + items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }], + }) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })) .resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] }) }) @@ -1307,7 +1310,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () => await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' })) .resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' })) - .resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] }) + .resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] }) await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' })) .rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND')) await search.dispose() diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index a83317ecf8..2b26ea8e43 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -7,12 +7,12 @@ - `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order. - `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus. - `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order. -- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title. +- `readTitleSnapshot(sessionId)` loads one live-preferred or persisted log and returns the cloned source header with its latest folded `session/title` event. `readTitle(sessionId)` is the title-only convenience view; it returns `undefined` when the known session has no title. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. - `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. - `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. +- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles. @@ -24,7 +24,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc ## Full-text methods -`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. +`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. An event-search page also carries the cloned target header from the same indexed generation as its hits, allowing authorization consumers to bind policy to the payload observation. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above. The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md). diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 2028f908c1..9f44a103fc 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -10,12 +10,12 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { SessionEventResultFilter, + SessionEventSearchPage, SessionEventReadRequest, SessionEventRecord, - SessionEventSearchHit, SessionEventSearchDocument, SessionEventSearchRequest, - SessionEventTrace, + SessionEventTraceObservation, SessionEventTraceRequest, SessionEventWindow, SessionLineageTrace, @@ -26,6 +26,7 @@ import type { SessionSearchPage, SessionSearchRequest, SessionSurfaceSnapshot, + SessionTitleObservation, } from './types.ts' import { SESSION_QUERY_READ_WINDOW_MAX, @@ -103,12 +104,12 @@ export abstract class SessionQueryService extends Service { * Search events within one live-preferred logical session. * @param request - target session, query text, filters, page size, and cursor. * @param exec - optional cancellation control. - * @returns matching event hits in deterministic relevance order. + * @returns matching event hits and their target header from one indexed generation. */ abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> + ): Promise /** * List the complete logical corpus using live-preferred records. @@ -134,8 +135,21 @@ export abstract class SessionQueryService extends Service { * @returns latest title snapshot, or `undefined` when the log has no title event. */ async readTitle(sessionId: SessionId): Promise { + return (await this.readTitleSnapshot(sessionId)).title + } + + /** + * Fold the latest title and return its source header from one corpus observation. + * @param sessionId - live or persisted session id to read. + * @returns cloned source header and optional latest title snapshot. + */ + async readTitleSnapshot(sessionId: SessionId): Promise { const loaded = await this._corpus.load(sessionId) - return foldSessionTitle(loaded.events) + const title = foldSessionTitle(loaded.events) + return { + session: loaded.header, + ...title === undefined ? {} : { title }, + } } /** @@ -204,12 +218,15 @@ export abstract class SessionQueryService extends Service { /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. - * @returns direct links plus the target's positional replacement chain. + * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ - async traceEvent(request: SessionEventTraceRequest): Promise { + async traceEvent(request: SessionEventTraceRequest): Promise { const loaded = await this._corpus.load(request.sessionId) - return tracing.traceEvent(request.sessionId, loaded.events, request.seq) + return { + session: loaded.header, + ...tracing.traceEvent(request.sessionId, loaded.events, request.seq), + } } /** diff --git a/packages/session-query/session-query/src/types.ts b/packages/session-query/session-query/src/types.ts index b231bd9f78..72537918e1 100644 --- a/packages/session-query/session-query/src/types.ts +++ b/packages/session-query/session-query/src/types.ts @@ -12,6 +12,7 @@ import type { SessionId, SurfaceEvent, } from '@deepseek-ai/dsh-session' +import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' import type { SessionSearchCursor } from './cursor.ts' export type { SessionSearchCursor } from './cursor.ts' @@ -108,6 +109,12 @@ export interface SessionEventTrace { derivedEventSeqs: number[] } +/** Event relationships bound to the same session-header observation. */ +export interface SessionEventTraceObservation extends SessionEventTrace { + /** Cloned header selected with the event log used for the trace. */ + session: SessionHeader +} + /** Request for one event plus raw neighboring log context. */ export interface SessionEventReadRequest { /** Session that owns the target event. */ @@ -134,6 +141,14 @@ export interface SessionEventWindow { endSeq: number } +/** Latest folded title bound to the same session-header observation. */ +export interface SessionTitleObservation { + /** Cloned header selected with the event log used for the title fold. */ + session: SessionHeader + /** Latest title snapshot, absent when the observed log has no title. */ + title?: SessionTitleSnapshot +} + /** Inclusive numeric interval used by time and sequence filters. */ export interface SessionResultRange { /** Inclusive lower bound. */ @@ -184,6 +199,12 @@ export interface SessionSearchPage { nextCursor?: SessionSearchCursor } +/** Event-search results bound to the indexed target-session observation. */ +export interface SessionEventSearchPage extends SessionSearchPage { + /** Cloned target header from the same indexed generation as `items`. */ + session: SessionHeader +} + /** Controls shared by cross-session and within-session search calls. */ export interface SessionSearchExecContext { /** Abort caller waiting and interrupt provider work where supported. */ diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 327048b8c1..e141487f32 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -213,8 +213,10 @@ it('registers exact and abstract search behavior under one ctx key', async () => const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(TestSessionQueryService) + const session = ctx.sessions.create(id) await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] }) - await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] }) + await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })) + .resolves.toEqual({ session: session.header, items: [] }) await fiber.dispose() expect(ctx.sessionQuery).toBeUndefined() }) diff --git a/packages/session-query/session-query/tests/test-service.ts b/packages/session-query/session-query/tests/test-service.ts index e37b0f71ff..9572e76e08 100644 --- a/packages/session-query/session-query/tests/test-service.ts +++ b/packages/session-query/session-query/tests/test-service.ts @@ -1,6 +1,6 @@ import SessionQueryService from '@deepseek-ai/dsh-session-query' import type { - SessionEventSearchHit, + SessionEventSearchPage, SessionEventSearchRequest, SessionSearchExecContext, SessionSearchHit, @@ -17,10 +17,13 @@ export class TestSessionQueryService extends SessionQueryService { return Promise.resolve({ items: [] }) } - override searchEvents( - _request: SessionEventSearchRequest, + override async searchEvents( + request: SessionEventSearchRequest, _exec?: SessionSearchExecContext, - ): Promise> { - return Promise.resolve({ items: [] }) + ): Promise { + return { + session: (await this.readSurface(request.sessionId)).session, + items: [], + } } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 02b6d2ceb8..92be54d46b 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -20,9 +20,10 @@ import { extractSessionEventText, type SessionAvailability, type SessionEventMetadataFilter, + type SessionEventSearchPage, type SessionEventSearchHit, type SessionEventSurface, - type SessionEventTrace, + type SessionEventTraceObservation, type SessionEventWindow, type SessionLineageNode, type SessionLineageTrace, @@ -352,7 +353,7 @@ async function executeSessionSearch( .map(hit => hit.header.parentSession) .filter((id): id is SessionIdValue => id !== undefined) const authorizedParents = await authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await readTitles(ctx, collected.items.map(hit => hit.header.id), exec.signal) + const titles = await readTitles(ctx, caller, collected.items.map(hit => hit.header.id), exec.signal) return formatSessionSearch(collected, titles, authorizedParents) } @@ -377,7 +378,7 @@ async function executeEventSearch( } range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1) } - const title = await readTitle(ctx, sessionId, exec.signal) + const title = await readTitle(ctx, caller, sessionId, exec.signal) if (range.from !== undefined && range.to !== undefined && range.from > range.to) { return formatEventSearch(sessionId, title, { items: [], capped: false }) } @@ -392,12 +393,16 @@ async function executeEventSearch( const collected = await collectPages( maxResults, exec.signal, - cursor => ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal }), + async (cursor): Promise => { + const page = await ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal }) + assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, () => true, ) return formatEventSearch(sessionId, title, collected) @@ -413,6 +418,7 @@ async function executeSessionTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await ctx.sessionQuery.traceSession(sessionId) exec.signal.throwIfAborted() + assertObservedTargetAuthorized(caller, sessionId, trace.target.header) const ancestors: SessionRecord[] = [] let ancestorBoundary = false @@ -430,7 +436,7 @@ async function executeSessionTrace( ...ancestors.map(record => record.header.id), ...descendantIds(descendants), ] - const titles = await readTitles(ctx, visibleIds, exec.signal) + const titles = await readTitles(ctx, caller, visibleIds, exec.signal) return formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) } @@ -445,7 +451,8 @@ async function executeEventTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }) exec.signal.throwIfAborted() - const title = await readTitle(ctx, sessionId, exec.signal) + assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventTrace(sessionId, title, trace) } @@ -467,7 +474,8 @@ async function executeEventRead( ...args.after === undefined ? {} : { after: args.after }, }) exec.signal.throwIfAborted() - const title = await readTitle(ctx, sessionId, exec.signal) + assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await readTitle(ctx, caller, sessionId, exec.signal) return formatEventRead(sessionId, title, window) } @@ -676,8 +684,20 @@ async function collectPages( } function recordAuthorized(record: SessionRecord, caller: Caller): boolean { - if (record.header.id === caller.id) return true - return caller.header.cwd !== undefined && record.header.cwd === caller.header.cwd + return headerAuthorized(record.header, caller) +} + +function headerAuthorized(header: SessionHeader, caller: Caller): boolean { + if (header.id === caller.id) return true + return caller.header.cwd !== undefined && header.cwd === caller.header.cwd +} + +function assertObservedTargetAuthorized( + caller: Caller, + target: SessionIdValue, + observed: SessionHeader, +): void { + if (observed.id !== target || !headerAuthorized(observed, caller)) throw unauthorizedTarget() } async function authorizeSessionIds( @@ -704,28 +724,32 @@ async function authorizeSessionIds( async function readTitles( ctx: Context, + caller: Caller, ids: readonly SessionIdValue[], signal: AbortSignal, ): Promise { const result = new Map() for (const id of new Set(ids)) { - result.set(id, await readTitle(ctx, id, signal)) + result.set(id, await readTitle(ctx, caller, id, signal)) } return result as CompleteTitleMap } async function readTitle( ctx: Context, + caller: Caller, id: SessionIdValue, signal: AbortSignal, ): Promise { signal.throwIfAborted() try { - const title = await ctx.sessionQuery.readTitle(id) + const observation = await ctx.sessionQuery.readTitleSnapshot(id) signal.throwIfAborted() - return { text: title?.title ?? 'untitled' } + assertObservedTargetAuthorized(caller, id, observation.session) + return { text: observation.title?.title ?? 'untitled' } } catch (error: unknown) { if (signal.aborted) signal.throwIfAborted() + if (error instanceof HarnessError && error.code === 'SESSION_QUERY_TOOL_UNAUTHORIZED') throw error const code = error instanceof HarnessError ? error.code : 'UNKNOWN' ctx.logger.warn(`tool-session-query: title read failed for session "${id}": ${fullError(error)}`) return { text: 'untitled', unavailableCode: code } @@ -866,7 +890,7 @@ function renderDescendants( function formatEventTrace( sessionId: SessionIdValue, title: TitleView, - trace: SessionEventTrace, + trace: SessionEventTraceObservation, ): string { return [ `Session ${sessionId} — ${titleText(title)}`, diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 017070eefc..ae62161f14 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -14,6 +14,7 @@ import SessionQueryService, { SessionQueryError, SessionSearchCursor, type SessionEventSearchHit, + type SessionEventSearchPage, type SessionEventSearchRequest, type SessionSearchExecContext, type SessionSearchHit, @@ -113,7 +114,10 @@ class FakeQuery extends SessionQueryService { static eventSearch: ( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ) => Promise> = () => Promise.resolve({ items: [] }) + ) => Promise = request => Promise.resolve({ + session: header(request.sessionId, '/work'), + items: [], + }) static sessionRequests: SessionSearchRequest[] = [] static eventRequests: SessionEventSearchRequest[] = [] @@ -122,7 +126,10 @@ class FakeQuery extends SessionQueryService { static reset(): void { this.sessionSearch = () => Promise.resolve({ items: [] }) - this.eventSearch = () => Promise.resolve({ items: [] }) + this.eventSearch = request => Promise.resolve({ + session: header(request.sessionId, '/work'), + items: [], + }) this.sessionRequests = [] this.eventRequests = [] this.searchSignals = [] @@ -141,22 +148,25 @@ class FakeQuery extends SessionQueryService { override searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, - ): Promise> { + ): Promise { FakeQuery.eventRequests.push(request) FakeQuery.searchSignals.push(exec?.signal) return FakeQuery.eventSearch(request, exec) } - override async readTitle(sessionId: SessionIdValue) { + override async readTitleSnapshot(sessionId: SessionIdValue) { const value = FakeQuery.titles.get(sessionId) if (value instanceof Error) throw value - if (value === undefined) return super.readTitle(sessionId) + if (value === undefined) return super.readTitleSnapshot(sessionId) return { - title: value, - messageSeqs: [], - source: { kind: 'fallback' as const }, - eventSeq: 0, - updatedAt: 1, + session: (await this.readSurface(sessionId)).session, + title: { + title: value, + messageSeqs: [], + source: { kind: 'fallback' as const }, + eventSeq: 0, + updatedAt: 1, + }, } } } @@ -477,6 +487,69 @@ describe('workspace authority and lineage redaction', () => { expect(output).toContain(mounted.caller.id) expect(output).toContain('persisted') }) + + it('rejects every payload observation whose target moved after pre-authorization', async () => { + const mounted = await mount() + const target = createSession(mounted.ctx, 'moving-target', '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'authorized payload' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const movedHeader = header(target.id, '/outside') + + FakeQuery.eventSearch = () => Promise.resolve({ + session: movedHeader, + items: [eventHit(target.id, 0, 'secret event hit')], + }) + const search = await mounted.call('session_event_search', { + session_id: target.id, + query: 'secret', + }) + expect(errorCode(search)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(search)).not.toContain('secret event hit') + + const lineage = await mounted.ctx.sessionQuery.traceSession(target.id) + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession').mockResolvedValueOnce({ + ...lineage, + target: { ...lineage.target, header: movedHeader }, + }) + expect(errorCode(await mounted.call('session_trace', { session_id: target.id }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + const eventTrace = await mounted.ctx.sessionQuery.traceEvent({ sessionId: target.id, seq: 0 }) + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent').mockResolvedValueOnce({ + ...eventTrace, + session: movedHeader, + }) + expect(errorCode(await mounted.call('session_event_trace', { session_id: target.id, seq: 0 }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + const eventWindow = await mounted.ctx.sessionQuery.readEvent({ sessionId: target.id, seq: 0 }) + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent').mockResolvedValueOnce({ + ...eventWindow, + session: movedHeader, + }) + expect(errorCode(await mounted.call('session_event_read', { session_id: target.id, seq: 0 }))) + .toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + + FakeQuery.sessionSearch = () => Promise.resolve({ + items: [sessionHit(target.id, '/work', 'safe hit')], + }) + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockResolvedValueOnce({ + session: movedHeader, + title: { + title: 'secret moved title', + messageSeqs: [], + source: { kind: 'fallback' }, + eventSeq: 0, + updatedAt: 1, + }, + }) + const titled = await mounted.call('session_search', { query: 'safe' }) + expect(errorCode(titled)).toBe('SESSION_QUERY_TOOL_UNAUTHORIZED') + expect(text(titled)).not.toContain('secret moved title') + }) }) describe('search paging, prior-history bounds, titles, and cancellation', () => { @@ -590,6 +663,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => it('intersects current-session search with the event before the latest step and leaves other targets unchanged', async () => { const mounted = await mount() FakeQuery.eventSearch = request => Promise.resolve({ + session: header(request.sessionId, '/work'), items: [eventHit(request.sessionId, 1)], }) await mounted.call('session_event_search', { @@ -633,8 +707,15 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const other = createSession(mounted.ctx, 'paged-events', '/work') const cursor = SessionSearchCursor('events-next') FakeQuery.eventSearch = request => request.cursor === undefined - ? Promise.resolve({ items: [eventHit(other.id, 1)], nextCursor: cursor }) - : Promise.resolve({ items: [eventHit(other.id, 2), eventHit(other.id, 3)] }) + ? Promise.resolve({ + session: header(other.id, '/work'), + items: [eventHit(other.id, 1)], + nextCursor: cursor, + }) + : Promise.resolve({ + session: header(other.id, '/work'), + items: [eventHit(other.id, 2), eventHit(other.id, 3)], + }) const result = await mounted.call('session_event_search', { session_id: other.id, query: 'q', @@ -663,7 +744,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const second = createSession(mounted.ctx, 'stackless-title', '/work') const stackless = new Error('stackless') Object.defineProperty(stackless, 'stack', { value: undefined }) - const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitle') + const readTitle = vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot') .mockRejectedValueOnce('string failure') .mockRejectedValueOnce(stackless) FakeQuery.sessionSearch = () => Promise.resolve({ @@ -685,7 +766,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => const hit = createSession(mounted.ctx, 'abort-title', '/work') const controller = new AbortController() FakeQuery.sessionSearch = () => Promise.resolve({ items: [sessionHit(hit.id, '/work')] }) - vi.spyOn(mounted.ctx.sessionQuery, 'readTitle').mockImplementation(() => { + vi.spyOn(mounted.ctx.sessionQuery, 'readTitleSnapshot').mockImplementation(() => { controller.abort() return Promise.reject(new Error('cancelled title')) }) diff --git a/packages/support/acp-snapshot/src/normalize.ts b/packages/support/acp-snapshot/src/normalize.ts index 258de0fd53..2de8d922db 100644 --- a/packages/support/acp-snapshot/src/normalize.ts +++ b/packages/support/acp-snapshot/src/normalize.ts @@ -13,12 +13,14 @@ const TOOLS = '{{tools}}' const MESSAGE_PREFIX = '{{messagePrefix}}' const UPDATED_AT = '{{updatedAt}}' const EVENT_TIME = '{{eventTime}}' +const EVENT_OMITTED_BYTES = '{{eventOmittedBytes}}' /** A cwd-rooted path after volatile cwd replacement, through its last separator-delimited segment. */ const CWD_ROOTED_PATH_RE = /\{\{cwd\}\}(?:[\\/][^\s<>"'`]+)+/g const PATH_TAG_RE = /()([^<]*)(<\/path>)/g const ADDITIONAL_INSTRUCTIONS_PATH_RE = /(Additional instructions from: )([^\r\n]+)/g const EMBEDDED_EVENT_TIME_RE = /("time": )\d+(?=,\r?\n)/g +const EVENT_READ_OMITTED_BYTES_RE = /(\r?\n\r?\n\(Omitted )\d+( bytes\.)/g const EVENT_READ_RESULT_RE = /^Session [^\r\n]+ — [^\r\n]+\r?\nTarget event seq \d+:\r?\n```json\r?\n\{\r?\n/ @@ -81,6 +83,7 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM // models, bash, or unrelated tools remains regression-visible. if (EVENT_READ_RESULT_RE.test(out)) { out = out.replace(EMBEDDED_EVENT_TIME_RE, `$1${EVENT_TIME}`) + out = out.replace(EVENT_READ_OMITTED_BYTES_RE, `$1${EVENT_OMITTED_BYTES}$2`) } for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID) out = out.replace(UUID_RE, SESSION_ID) diff --git a/packages/support/acp-snapshot/tests/normalize.spec.ts b/packages/support/acp-snapshot/tests/normalize.spec.ts index b4bc813cda..a4d0b4ad95 100644 --- a/packages/support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/support/acp-snapshot/tests/normalize.spec.ts @@ -134,7 +134,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```', + text: 'Session prior — title\nTarget event seq 4:\n```json\n{\n "seq": 4,\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', }, }], }, @@ -142,7 +142,9 @@ Additional instructions from: nested\AGENTS.md`, }) const out = normalizeStdout(raw, ctx) expect(out).toContain('\\"time\\": {{eventTime}}') + expect(out).toContain('Omitted {{eventOmittedBytes}} bytes') expect(out).not.toContain('1784876275593') + expect(out).not.toContain('39387') }) it('preserves event-like timestamps in unrelated output text', () => { @@ -156,7 +158,7 @@ Additional instructions from: nested\AGENTS.md`, type: 'content', content: { type: 'text', - text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```', + text: 'bash output:\n```json\n{\n "time": 1784876275593,\n "data": {}\n}\n```\n\n(Omitted 39387 bytes. Full formatted result stored at: /tmp/result.txt.)', }, }], }, @@ -164,7 +166,9 @@ Additional instructions from: nested\AGENTS.md`, }) const out = normalizeStdout(raw, ctx) expect(out).toContain('1784876275593') + expect(out).toContain('39387') expect(out).not.toContain('{{eventTime}}') + expect(out).not.toContain('{{eventOmittedBytes}}') }) it('throws on a non-JSON stdout line (the purity check)', () => { diff --git a/packages/ui/acp/tests/harness.ts b/packages/ui/acp/tests/harness.ts index fa7700c5f3..155ccb5b34 100644 --- a/packages/ui/acp/tests/harness.ts +++ b/packages/ui/acp/tests/harness.ts @@ -45,9 +45,12 @@ class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/packages/ui/tui/tests/session-query.ts b/packages/ui/tui/tests/session-query.ts index d9083ad6d1..67efcebf45 100644 --- a/packages/ui/tui/tests/session-query.ts +++ b/packages/ui/tui/tests/session-query.ts @@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService { } override searchEvents( - ..._args: Parameters + ...args: Parameters ): ReturnType { - return Promise.resolve({ items: [] }) + return this.readSurface(args[0].sessionId).then(surface => ({ + session: surface.session, + items: [], + })) } } diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 8318d59996..ef924b1b03 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -124,8 +124,10 @@ export const LINK_MAP: Record = { SessionEventResultFilter: 'session-query.md', SessionEventSearchDocument: 'session-query.md', SessionEventSearchHit: 'session-query.md', + SessionEventSearchPage: 'session-query.md', SessionEventSearchRequest: 'session-query.md', SessionEventTrace: 'session-query.md', + SessionEventTraceObservation: 'session-query.md', SessionEventTraceRequest: 'session-query.md', SessionEventWindow: 'session-query.md', SessionLineageTrace: 'session-query.md', @@ -135,6 +137,7 @@ export const LINK_MAP: Record = { SessionSearchHit: 'session-query.md', SessionSearchPage: 'session-query.md', SessionSearchRequest: 'session-query.md', + SessionTitleObservation: 'session-query.md', SessionTitleProvider: 'session-title.md', SessionTitleSnapshot: 'session-title.md', SkillDefinition: 'skills.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 89a0778827..6b7d86f77b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -394,6 +394,11 @@ "symbol": "SessionSurfaceSnapshot", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionTitleObservation", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", @@ -434,6 +439,11 @@ "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventTraceObservation", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-reference.md", "symbol": "SessionReferenceInput", @@ -1194,6 +1204,11 @@ "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" }, + { + "doc": "docs/core-data-structures/session-query.md", + "symbol": "SessionEventSearchPage", + "source": "packages/session-query/session-query/src/types.ts" + }, { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit",