Merge branch 'master' into worktree/llm-mock-fault-server

This commit is contained in:
Tianyi Cui
2026-07-26 00:45:44 +08:00
committed by GitHub
162 changed files with 9333 additions and 706 deletions

View File

@@ -65,7 +65,7 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.

View File

@@ -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: [],
}))
}
}

View File

@@ -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}',

View File

@@ -359,7 +359,7 @@ describe('config-driven session id', () => {
await ctx2.fiber.dispose()
})
it('config-driven resumeSessionId continues a persisted session (env-var resume)', async () => {
it('config-driven resumeSessionId continues a persisted session', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-'))
dirs.push(root)

View File

@@ -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) {

View File

@@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.

View File

@@ -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

View File

@@ -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:^",

View File

@@ -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')
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../agent-spine-demo"
},

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-tui-demo
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
## What it bakes in
@@ -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 |
@@ -47,9 +47,9 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
## The bin
## Front door
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
## Example leaf

View File

@@ -1,14 +1,11 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-tui-demo": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
@@ -18,26 +15,19 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
@@ -60,9 +50,7 @@
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env node
/**
* Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
* dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs.
* @module @deepseek-ai/dsh-tui-demo/bin
*/
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const NAME = 'dsh-tui-demo'
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
the built-bin fail-loud smoke */
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is
// logged per-entry rather than rethrown, so a piped launch would otherwise
// settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; `
+ 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
process.exit(1)
}
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
/* v8 ignore stop */

View File

@@ -64,8 +64,8 @@ export interface Config {
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes via this app's env var, e.g.
* `RESUME_SESSION_ID={session} dsh`.
* door). Set it to a command that resumes the session, e.g.
* `dsh --resume {session}`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */

View File

@@ -1,98 +0,0 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
* one sanctioned PTY surface).
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
// Symlink each package the bin imports at module load by package name so plain
// Node resolves its built `main`, matching an installed dependency rather than
// tsconfig paths.
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
return json.name
}
/** Build a temporary external consumer with built workspace/vendor links. */
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
const abs = join(repoRoot, 'packages', rel)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
return dir
}
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// NO tsx — this is the published `node lib/bin.js` path; the guard fires
// before the Loader resolves the config tree.
const child = spawn(process.execPath, [tuiBin, './cordis.yml'], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
let consumer: string | undefined
afterEach(async () => {
// Windows can briefly retain released handles after exit; retry removal.
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
consumer = await makeConsumer()
const { stdout, code, stderr } = await runBuiltBin(consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh-cli-demo')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -14,12 +14,6 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../ui/app-boot"
},
{
"path": "../../core/agent"
},

View File

@@ -1,14 +1,14 @@
import { defineConfig } from 'tsdown'
/**
* tui-demo ships two entries: the plugin (`index`) and the CLI `bin`
* (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
* The root tsdown builds only `lib/types/index.js`, so this override adds
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
* matching every package.
* tui-demo ships the plugin (`index`) and its invariant companion; the CLI
* front door is `dsh` (apps/cli), which mounts this bundle through its config.
* The root tsdown builds only `lib/types/index.js`, so this override adds the
* invariant entry. Declarations come from `tsc -b` (dts: false), matching
* every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)
})

View File

@@ -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).

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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.

View File

@@ -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

View File

@@ -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')
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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. */

View File

@@ -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),

View File

@@ -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(

View File

@@ -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. */

View File

@@ -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()
})

View File

@@ -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 () => {

View File

@@ -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: [],
}
}
}

View 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.

View 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"
}
}

View 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 }
}

View 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,
}

View 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 */

View 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,
}

View 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,
}

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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

View 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"
}
]
}

View File

@@ -53,7 +53,7 @@ Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canon
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config Agent Note](../../../.agents/notes/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log expected outputs, and each pin's prompt and tool-schema sidecars from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). The launcher and suite factory are ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection` — while the normalizers are transport-neutral session-log/text helpers also consumed by the TUI snapshot suite and the web browser e2e lane. Input scripts cover initialization, fresh-session creation, text prompting, cancellation, expected RPC failures, and durable turn-boundary waits. Permission round-trips are a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) mapped to the agent-issued `optionId`; an absent or exhausted queue answers `cancelled`, and an unoffered kind rejects the run.
## Model Experience

View File

@@ -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

View File

@@ -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()

View File

@@ -2,7 +2,7 @@
A replay LLM plugin for keyless snapshot tests. It yields model streams reconstructed from a recorded **session JSONL** fixture, so a test can boot the real agent against a fixed model transcript with no API key. With `providers` configured it registers a replay-only adapter whose catalog is available to scenarios that exercise model discovery; without `providers` it installs the catch-all `llm/stream` waterfall used by tests that do not need discovery.
Its consumers are the ACP snapshot harness in `examples/acp-agent` and the `stream-json` snapshot in `examples/headless-agent`; each loads this plugin in place of a real LLM adapter. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
Its consumers are the ACP, headless `stream-json`, and TUI snapshot suites plus the web browser e2e lane. Loader-driven suites mount this plugin in place of a real LLM adapter; the web lane installs it directly to retain the teardown consumption handle. Keeping derivation and replay here places that logic under the per-file 100% coverage gate on `packages/*/src`.
## How the fixture works
@@ -24,6 +24,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. |
| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. |
| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. |
| `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. |
```yaml
- id: llm-replay
@@ -43,11 +44,11 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s
## Exports
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars.
- `installLlmReplay(ctx, config)` — install the configured replay adapter or catch-all `llm/stream` listener; returns a `ReplayHandle` (`dispose()` for HMR safety plus `assertConsumed()`, the teardown check that every recorded script bound to a live session and every bound cursor drained — turning a scenario that silently drove fewer model calls than recorded into a crisp diagnostic). Use this in tests to drive replay without the Loader or env vars.
- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order.
- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing).
- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `Config`.
- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `ReplayProviderConfig` / `ReplayModelConfig` / `ReplayHandle` / `Config`.
## Plugin export shape

View File

@@ -78,6 +78,32 @@ export interface ReplayConfig {
* by tests that do not need discovery.
*/
providers?: ReplayProviderConfig[]
/**
* Optional per-chunk pacing delay in milliseconds: each replayed chunk waits
* this long before yielding, so a downstream transport (e.g. the web SSE
* mux observed by a browser) sees genuinely incremental delivery. A realism
* knob only — correctness must never depend on it. Absent or `0` keeps
* today's synchronous burst yield. Must be a non-negative finite integer;
* aborting mid-wait cancels the stream like any other abort.
*/
paceMs?: number
}
/**
* Handle returned by {@link installLlmReplay}: removal plus the end-of-run
* consumption check that turns silent fixture underruns (a scenario that
* issued fewer calls than recorded, or never bound a recorded child script)
* into a crisp diagnostic at teardown.
*/
export interface ReplayHandle {
/** Remove the registered adapter or waterfall listener (HMR safety). Freestanding closure — safe to destructure. */
dispose(this: void): void
/**
* Throw unless every recorded script was bound to a live session and every
* bound cursor consumed its full entry list. Call at scenario teardown.
* Freestanding closure — safe to destructure.
*/
assertConsumed(this: void): void
}
/**
@@ -281,12 +307,32 @@ class ReplayAdapter extends LlmAdapter {
}
}
/**
* Wait `paceMs` between chunk yields, aborting the wait (and the stream) the
* moment the signal fires — a paced replay must cancel as promptly as a burst
* one.
*/
function paceDelay(paceMs: number, signal: AbortSignal | undefined): Promise<void> {
return new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, paceMs)
const onAbort = (): void => {
clearTimeout(timer)
reject(new Error('aborted'))
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
/** Yield a recorded stream back, honoring abort like a real adapter. */
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable<StreamChunk> {
async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined, paceMs: number): AsyncIterable<StreamChunk> {
switch (entry.kind) {
case 'chunks':
for (const chunk of entry.chunks) {
if (signal?.aborted) throw new Error('aborted')
if (paceMs > 0) await paceDelay(paceMs, signal)
yield chunk
}
return
@@ -297,6 +343,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
// mid-stream STREAM_CLOSED after partial chunks).
for (const chunk of entry.chunks) {
if (signal?.aborted) throw new Error('aborted')
if (paceMs > 0) await paceDelay(paceMs, signal)
yield chunk
}
throw new LlmError(entry.message, entry.code)
@@ -324,14 +371,17 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined)
* next ordered recorded script, then advances its own cursor synchronously at
* invocation time; calls without `sessionId` share one anonymous session. A
* non-empty provider catalog registers a routed replay adapter; otherwise a
* catch-all waterfall intercepts requests. Returns the effect disposer for
* HMR-safe removal.
* catch-all waterfall intercepts requests.
*
* @param ctx - the context whose LLM service receives the replay route or waterfall.
* @param config - the resolved fixture paths (env-var defaulting is `apply`'s job).
* @returns the disposer that removes the registered adapter or listener.
* @returns the {@link ReplayHandle} carrying the disposer and the teardown consumption check.
*/
export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void {
export function installLlmReplay(ctx: Context, config: ReplayConfig): ReplayHandle {
const paceMs = config.paceMs ?? 0
if (!Number.isInteger(paceMs) || paceMs < 0) {
throw new Error(`llm-replay: paceMs must be a non-negative integer, got ${String(config.paceMs)}`)
}
const scripts = loadSessionScripts(config)
// Live-session → its bound script + cursor. A new live session id claims the
// next not-yet-bound script (scripts are in bind order); `nextScript` is the
@@ -375,14 +425,31 @@ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void
+ `but its script has only ${boundState.entries.length}; re-record the scenario`,
)
}
yield* replayEntry(entry, options.signal)
yield* replayEntry(entry, options.signal, paceMs)
})()
}
const providers = config.providers ?? []
if (providers.length > 0) {
return ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
const dispose = providers.length > 0
? ctx.llm.registerAdapter(providers.map(provider => provider.id), new ReplayAdapter(providers, replay))
: ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
return {
dispose,
assertConsumed(): void {
const problems: string[] = []
if (nextScript < scripts.length) {
problems.push(`${scripts.length - nextScript} recorded script(s) never bound to a live session`)
}
for (const [key, state] of bound) {
if (state.cursor < state.entries.length) {
const who = key === ANON ? 'the anonymous session' : `session ${key}`
problems.push(`${who} consumed ${state.cursor}/${state.entries.length} recorded call(s)`)
}
}
if (problems.length > 0) {
throw new Error(`llm-replay: fixture not fully consumed — ${problems.join('; ')}; the scenario drove fewer model calls than recorded`)
}
},
}
return ctx.on('llm/stream', (options: GenerateOptions, _next) => replay(options))
}
export const name = 'llm-replay'
@@ -402,6 +469,8 @@ export interface Config {
childFiles?: string[]
/** Optional replay-only provider catalog; absent or empty selects catch-all waterfall replay. */
providers?: ReplayProviderConfig[]
/** Optional per-chunk pacing delay in ms (see {@link ReplayConfig.paceMs}); absent keeps burst yield. */
paceMs?: number
}
export function apply(ctx: Context, config: Config = {}): void {
@@ -418,5 +487,6 @@ export function apply(ctx: Context, config: Config = {}): void {
...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {},
...childFiles.length > 0 ? { childFiles } : {},
...config.providers !== undefined ? { providers: config.providers } : {},
...config.paceMs !== undefined ? { paceMs: config.paceMs } : {},
})
}

View File

@@ -234,7 +234,7 @@ describe('installLlmReplay (through the real LlmService)', () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const dispose = installLlmReplay(ctx, {
const { dispose } = installLlmReplay(ctx, {
file,
providers: [
{
@@ -431,6 +431,92 @@ describe('installLlmReplay (through the real LlmService)', () => {
await iterator.next()
await expect(iterator.next()).rejects.toThrow('aborted')
})
it('rejects a paceMs that is not a non-negative integer', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
expect(() => installLlmReplay(ctx, { file, paceMs: -1 })).toThrow(/paceMs/)
expect(() => installLlmReplay(ctx, { file, paceMs: 1.5 })).toThrow(/paceMs/)
})
it('paces chunk yields when paceMs is set (each chunk waits at least the pace)', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, paceMs: 10 })
const started = performance.now()
const chunks = await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
expect(chunks).toEqual(TEXT_CHUNKS)
// N chunks × 10ms; allow generous scheduling slack, assert the floor only.
expect(performance.now() - started).toBeGreaterThanOrEqual(TEXT_CHUNKS.length * 10 - 5)
})
it('aborting DURING a pace wait cancels the stream promptly', async () => {
writeLog(TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, paceMs: 60_000 })
const controller = new AbortController()
const pending = drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], signal: controller.signal }))
// Let the generator park inside the pace timer, then abort — the reject
// must come from the abort listener, not the (distant) timer.
await new Promise(r => setImmediate(r))
controller.abort()
await expect(pending).rejects.toThrow('aborted')
})
it('assertConsumed passes only after every recorded call replayed', async () => {
writeLog(TEXT_CHUNKS, TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file })
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
// One of two recorded calls consumed — the underrun must name the gap.
expect(() => { handle.assertConsumed() }).toThrow(/consumed 1\/2 recorded call/)
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))
expect(() => { handle.assertConsumed() }).not.toThrow()
})
it('paces a throw-entry prefix too (the recorded partial streams at the same cadence)', async () => {
writeFileSync(file, sessionJsonl([]), 'utf8')
const overrideFile = join(dir, 'replay.override.json')
const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }]
writeFileSync(overrideFile, JSON.stringify([
{ kind: 'throw', chunks: partial, message: 'boom', code: 'STREAM_CLOSED' },
]), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
installLlmReplay(ctx, { file, overrideFile, paceMs: 10 })
const started = performance.now()
await expect(drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).rejects.toThrow('boom')
expect(performance.now() - started).toBeGreaterThanOrEqual(5)
})
it('assertConsumed names an underrunning identified session by its id', async () => {
writeLog(TEXT_CHUNKS, TEXT_CHUNKS)
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file })
const sessionId = 'live-underrun' as NonNullable<GenerateOptions['sessionId']>
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId }))
expect(() => { handle.assertConsumed() }).toThrow(/session live-underrun consumed 1\/2/)
})
it('assertConsumed reports recorded scripts no live session ever bound', async () => {
writeLog(TEXT_CHUNKS)
const childFile = join(dir, 'session.1.jsonl')
writeFileSync(childFile, sessionJsonl(
TEXT_CHUNKS.map((chunk, i) => chunkEvent(i + 1, 1, 1, chunk)),
{ id: 'child', createdAt: 10 },
), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
const handle = installLlmReplay(ctx, { file, childFiles: [childFile] })
await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [], sessionId: 'live-parent' as NonNullable<GenerateOptions['sessionId']> }))
// The child script never bound: the scenario drove fewer sessions than recorded.
expect(() => { handle.assertConsumed() }).toThrow(/1 recorded script\(s\) never bound/)
})
})
describe('parseSessionHeader', () => {
@@ -631,7 +717,7 @@ describe('apply (the plugin entry)', () => {
writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8')
const ctx = new Context()
await ctx.plugin(LlmService)
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }] })
apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 })
expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }])
expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS)
})

View File

@@ -5,7 +5,7 @@ import {
resolveExampleMode,
} from '@deepseek-ai/dsh-loader-smoke'
const SRC_BIN = '/repo/packages/examples/tui-demo/src/bin.ts'
const SRC_BIN = '/repo/packages/examples/cli-demo/src/bin.ts'
const TSCONFIG = '/repo/tsconfig.json'
const originalMode = process.env[EXAMPLE_MODE_ENV]
@@ -65,7 +65,7 @@ describe('resolveExampleLaunch', () => {
env: { DSH_HOME: '/tmp/home' },
})
expect(args).not.toContain('--import')
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
expect(args.slice(-2)).toEqual(['--config', './cordis.yml'])
expect(env.TSX_TSCONFIG_PATH).toBeUndefined()
expect(env.DSH_HOME).toBe('/tmp/home')
@@ -100,6 +100,6 @@ describe('resolveExampleLaunch', () => {
it('defaults the mode from the environment', () => {
process.env[EXAMPLE_MODE_ENV] = 'lib'
const { args } = resolveExampleLaunch({ srcBin: SRC_BIN })
expect(args).toContain('/repo/packages/examples/tui-demo/lib/bin.js')
expect(args).toContain('/repo/packages/examples/cli-demo/lib/bin.js')
})
})

View File

@@ -17,4 +17,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
The runnable app bundles that bake these interfaces into boot bins live in [`examples/`](../examples/README.md), composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md). `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

View File

@@ -1,17 +1,16 @@
# `@deepseek-ai/dsh-app-boot`
Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
| Export | Role |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |

View File

@@ -1,5 +1,5 @@
/**
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
@@ -36,62 +36,6 @@ export function resolveConfigPath(
return resolve(dir, replayName)
}
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
const RESUME_FLAG = '--resume'
/**
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
* vector, returning the resumed session id (when the flag is present) and the
* remaining arguments with the flag and its value removed — so a positional
* config path stays readable regardless of the flag's position. A `--resume`
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
* throws: a mistyped resume must fail loud, never silently start a fresh
* session. The id is not validated here; an unknown id fails loud downstream
* when the session cannot load.
* @param argv - the CLI arguments after subcommand dispatch.
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
*/
export function parseResumeArg(
argv: readonly string[],
): { resumeSessionId: string | undefined; rest: string[] } {
const rest: string[] = []
let resumeSessionId: string | undefined
let skipNext = false
for (const [i, arg] of argv.entries()) {
if (skipNext) {
skipNext = false
continue
}
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
if (arg === RESUME_FLAG || inlineValue) {
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
// A following token that is itself resume syntax (`--resume --resume x`)
// is a missing id, not a session literally named `--resume…`.
if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
}
resumeSessionId = value
skipNext = !inlineValue // the space form consumed the following token as its value
continue
}
rest.push(arg)
}
return { resumeSessionId, rest }
}
/**
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
* @param argv - current arguments after command dispatch.
* @param sessionId - selected session id.
* @returns flag-normalized arguments for a process replacement.
*/
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
const { rest } = parseResumeArg(argv)
return [...rest, RESUME_FLAG, sessionId]
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
@@ -212,6 +156,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
* makes `id` readable as the bare identifier `resumeSessionId` in a config
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
* so resuming a session needs no environment variable. A bin that never
* provides it leaves the identifier undeclared, so configs read it defensively
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
*/
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
* tree settles. Entry names load through the Loader's internal module loader

View File

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -30,40 +30,6 @@ describe('resolveConfigPath', () => {
})
})
describe('parseResumeArg', () => {
it('returns no resume id and passes arguments through when the flag is absent', () => {
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
})
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
})
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
})
it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
})
})
describe('replaceResumeArg', () => {
it('keeps positional arguments and replaces either existing flag form', () => {
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()

View File

@@ -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: [],
}))
}
}

View File

@@ -650,7 +650,7 @@ describe('TUI terminal-state snapshots', () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
const harness = await setupSnapshot({
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
config: { resumeCommand: 'dsh --resume {session}' },
sessionPersistence: {
list: async () => [earlier],
load: async () => ({

View File

@@ -206,7 +206,7 @@ describe('TUI config', () => {
})
describe('resume command and /resume', () => {
const RESUME = 'RESUME_SESSION_ID={session} dsh'
const RESUME = 'dsh --resume {session}'
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
({ version: 0, id: SessionId(id), createdAt, cwd })
const resumeEvents = (
@@ -234,7 +234,7 @@ describe('resume command and /resume', () => {
result.terminal.send('/exit')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh')
expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
expect(result.exit).toHaveBeenCalledWith(0)
await dispose(result)
})
@@ -1000,7 +1000,7 @@ describe('resume command and /resume', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session')
expect(result.terminal.output).toContain('dsh --resume fallback-session')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})