Quellcode durchsuchen

fix(headless): require the query service for every --session-id run

Address the ds-review-bot v9 review on PR #3849:

- Require `sessionQuery` before the live fast path too: a later process has no
  live Agent and must find the id through the query service, so a live identity
  no longer bypasses the check.
- Soften the live `stat` comment to what it proves: a stored record rules out an
  in-memory registration, while write-handle ownership is not queryable.
- Complete the `parseArguments` JSDoc and fix the broken English sentence in
  README.md and the Agent Note.
lsdsjy vor 2 Wochen
Ursprung
Commit
755b44bbcd

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md
-2026-09-09-headless-machine-readable-run-surface.md: 7bcca9226a8620d1eae83ecb269f680e6d1c54d4
-2026-09-09-headless-machine-readable-run-surface.zh.md: ce7047a4113c7ff794a3cbb88976c4760764ca2f
+2026-09-09-headless-machine-readable-run-surface.md: 9bc7a55beb86d0836288e376df6b544bd14c5222
+2026-09-09-headless-machine-readable-run-surface.zh.md: c8577c7849010e8bacf697a6b5a008c57a4b33ee

+ 2 - 2
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.md

@@ -54,7 +54,7 @@ Projection rules:
 - Text and reasoning are projected only from a committed `assistant/message`, never from live attempt deltas. A retried or discarded attempt appends `assistant/attempt`, which the projection ignores, so the stream never carries content the durable log does not contain ([publish state only at its commit point](../../../../packages/AGENTS.md)).
 - Text and reasoning are projected only from a committed `assistant/message`, never from live attempt deltas. A retried or discarded attempt appends `assistant/attempt`, which the projection ignores, so the stream never carries content the durable log does not contain ([publish state only at its commit point](../../../../packages/AGENTS.md)).
 - Each committed content block becomes exactly one `text` or `thinking` event in content order; `tool-call` blocks are not projected because the `tool/call` event owns them. `user/message` echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
 - Each committed content block becomes exactly one `text` or `thinking` event in content order; `tool-call` blocks are not projected because the `tool/call` event owns them. `user/message` echoes and internal session events (title, model selection, projection, checkpoint, goal, subagent) are not projected.
 - A `tool/result` is projected only when its `surfaceOp` is `append`. A compaction replacement of an older result is history, and projecting it would emit a call id with no matching `tool_call`.
 - A `tool/result` is projected only when its `surfaceOp` is `append`. A compaction replacement of an older result is history, and projecting it would emit a call id with no matching `tool_call`.
-- Every projected string and object key is bounded at 8 KiB, an event with a cut value carries `truncated: true`, and one serialized event line is bounded at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth so no legal input can overflow the bounding recursion. This includes the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter, an empty tool-argument string projects as `{}` to match the executor, and arguments JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text instead of the `null` serialization would report. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
+- Every projected string and object key is bounded at 8 KiB, an event with a cut value carries `truncated: true`, and one serialized event line is bounded at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth so no legal input can overflow the bounding recursion. This includes the process-level `error` event; a literal `__proto__` argument key is copied as data rather than through the inherited setter, an empty tool-argument string projects as `{}` to match the executor, and arguments that JSON cannot round-trip (an overflowing number such as `1e400`) keep their raw text rather than the `null` that `JSON.stringify` would report. The terminal `final` event is deliberately unbounded: it carries the same lossless answer the default mode prints.
 - Text and reasoning arrive when the step commits, not per token; default-mode stderr reasoning remains the only live text channel. A turn that fails in-turn still ends the stream with `final` and no `error` event, so a supervisor classifies that run from the exit code and the `turn_end` reason even when the stream is well formed.
 - Text and reasoning arrive when the step commits, not per token; default-mode stderr reasoning remains the only live text channel. A turn that fails in-turn still ends the stream with `final` and no `error` event, so a supervisor classifies that run from the exit code and the `turn_end` reason even when the stream is well formed.
 - `usage` appears on `step_end`, matching the token accounting a provider reports per step.
 - `usage` appears on `step_end`, matching the token accounting a provider reports per step.
 - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
 - Raw session events stay out of scope. A debug escape hatch can be added later without changing this vocabulary.
@@ -65,7 +65,7 @@ The runtime owns identity. A run without `--session-id` mints `session-<uuid>` a
 
 
 `--session-id <id>` is adopt-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)). The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.
 `--session-id <id>` is adopt-or-create: observe the persisted session, resume it when it exists, create it otherwise. Create-only would fail the second run, because the JSONL store rejects an existing log id ([session persistence](../../implemented/architecture/2026-06-14-session-persistence.md)). The id is opaque, so the runner validates non-emptiness on the trimmed value and passes the caller's exact string through, whitespace included.
 
 
-Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any `agent-preset/selected` event — because a blank session may switch preset after creation while the header stays a creation fact, and a malformed selection record fails closed rather than reading as no preset. A session linked to a parent or subagent — including a user fork — is rejected. All checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed `sessionQuery` service and fails loudly when `--session-id` is requested without it, or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing.
+Adoption compares the persisted session's recorded cwd with the process cwd, since sessions are organized per project directory ([project session directories](../../implemented/architecture/2026-07-24-project-session-directories.md)). A mismatch exits 1 with a `dsh:` diagnostic instead of silently continuing a conversation rooted elsewhere, and a session that recorded no cwd is rejected for the same reason. A session running under an agent preset is rejected because this bundle composes no preset roster: resuming it here would run it under the headless tools and prompts instead of the composition its log records. The check reads the preset the log currently records — the creation header advanced by any `agent-preset/selected` event — because a blank session may switch preset after creation while the header stays a creation fact, and a malformed selection record fails closed rather than reading as no preset. A session linked to a parent or subagent — including a user fork — is rejected. All checks run when a live Agent already holds the requested id, so a live identity cannot bypass them. Two live processes cannot write one id; the store's write lease already rejects the second writer. The runner reads the observation through the composed `sessionQuery` service and fails loudly when `--session-id` is requested without it — including when a live Agent already holds the id, because a later process has to find it — or when the requested identity would lack the `sessionPersistence` service that makes it durable; a live identity must also carry a stored record, because one registered only in memory would flush nothing.
 
 
 ## Consequences
 ## Consequences
 
 

+ 1 - 1
.agents/notes/implemented/feature/2026-09-09-headless-machine-readable-run-surface.zh.md

@@ -65,7 +65,7 @@ dsh --profile headless [--json] [--session-id <id>] [<task>... | -]
 
 
 `--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
 `--session-id <id>` 是采用或创建:先观察持久化会话,存在就 resume,不存在就 create。只创建会让第二次运行失败,因为 JSONL 存储拒绝已存在的日志 id(见 [session persistence](../../implemented/architecture/2026-06-14-session-persistence.zh.md))。标识是不透明的,因此 runner 只在 trim 后的值上校验非空,并把调用方的原始字符串(含空白字符)原样传下去。
 
 
-采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。
+采用时会比较持久化会话记录的 cwd 与进程 cwd,因为会话按项目目录组织(见 [project session directories](../../implemented/architecture/2026-07-24-project-session-directories.zh.md))。不一致时以 `dsh:` 诊断退出 1,而不是静默续接一个根目录在别处的会话;未记录 cwd 的会话出于同样理由被拒绝。运行在 agent preset 下的会话被拒绝,因为本 bundle 不组合任何 preset roster:在这里 resume 它,会用 headless 的工具与提示词运行它,而不是它日志当前记录的组合。该检查读取日志当前记录的 preset——创建 header 再叠加任何 `agent-preset/selected` 事件——因为空白会话可能在创建后切换 preset,而 header 始终只是创建事实;畸形的选择记录会失败关闭,而不会读成「无 preset」。带父会话或子 agent 关联的会话——包括用户 fork 出的会话——被拒绝。当某个存活 Agent 已经持有请求的 id 时,上述检查全部执行,因此存活身份无法绕过它们。两个存活进程不能写同一个 id;存储的写租约已经会拒绝第二个写入者。runner 通过已组合的 `sessionQuery` 服务读取观察结果,并在请求 `--session-id` 却没有该服务时显式失败——即便本进程已有存活 Agent 持有该 id,因为后续进程仍需找到它;若所请求的身份缺少让它持久化的 `sessionPersistence` 服务,同样显式失败;存活身份还必须已有持久化记录,因为仅注册在内存中的身份不会写入任何内容。
 
 
 ## 后果
 ## 后果
 
 

+ 2 - 2
packages/bundle/headless/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
 #   pnpm run verify-translation-pairing --write packages/bundle/headless/README.md
-README.md: c21084314349471258b64584d00266449dc57c88
-README.zh.md: 63129a4803b2dcde24866fb7a112ecce84fc1b62
+README.md: 8e9e57d8126715d8dcf6e83bb74e788e2f1e88c7
+README.zh.md: 3a8f28763dfc670e02fa86493a1aacd1c0a20921

+ 2 - 2
packages/bundle/headless/README.md

@@ -51,11 +51,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
 
 
 ### Choosing the session identity
 ### Choosing the session identity
 
 
-Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require the composed `sessionPersistence` service, so a profile that omits it fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
+Every invocation defaults to a fresh `session-<uuid>` identity. Pass `--session-id <id>` to name it yourself: the runner adopts the persisted Session with that id when one exists, and creates it otherwise. Both paths require the composed `sessionPersistence` and `sessionQuery` services, so a profile that omits either fails loudly instead of returning an id whose history dies with the process; a live identity must also carry a stored record, because an Agent registered only in memory would flush nothing. The identity is opaque, so the exact string is used, whitespace included. Adoption is scoped to the current working directory and refuses a Session that is a subagent or forked session, that recorded no working directory, that runs under an agent preset this profile does not compose, or whose preset record is malformed — the check reads the preset the Session log currently records, so a Session that switched preset while blank is rejected too. A supervisor therefore cannot silently drive someone else's conversation under a different composition; any mismatch fails before the task runs.
 
 
 ### Machine-readable output
 ### Machine-readable output
 
 
-`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`, and one event line is capped at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth. An empty tool-argument string projects as `{}`, matching what the executor runs, while arguments JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text instead of the `null` serialization would report. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
+`--json` replaces the final-text stdout line with a newline-delimited JSON event stream, while stderr keeps only the `dsh:` diagnostics. The stream opens with `session` (carrying the identity the run used) and closes with `final`, and carries `status`, `text`, `thinking`, `tool_call`, and `tool_result` events in between. `text` and `thinking` are projected from committed assistant messages, so a retried or discarded attempt never reaches the stream; they arrive when the step commits, not per token, and default-mode stderr reasoning remains the only live text channel. The terminal `final` event carries the same lossless answer as the default mode and is not capped; every other string and object key is capped at 8 KiB and flagged with `truncated`, and one event line is capped at 32 KiB — an over-long event keeps its scalar fields, drops structured ones, and at the extreme reduces to `type` and `truncated`, while a payload nested 64 levels or deeper is cut at that depth. An empty tool-argument string projects as `{}`, matching what the executor runs, while arguments that JSON cannot round-trip — an overflowing number such as `1e400` — keep their raw text rather than the `null` that `JSON.stringify` would report. A process-level failure outside a turn writes an `error` event and ends the stream without `final`, in addition to the `dsh:` stderr line. A turn that fails in-turn still ends with a `final` event (often empty) and no `error` event, so a well-formed stream can still describe a failed run: treat exit code 1 and the `turn_end` reason as the failure signal.
 
 
 ### When to use it
 ### When to use it
 
 

+ 1 - 1
packages/bundle/headless/README.zh.md

@@ -51,7 +51,7 @@ agent(智能体)会完成该任务,把提供方的每个非空推理增量
 
 
 ### 选择 Session 标识
 ### 选择 Session 标识
 
 
-每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 服务,因此缺少该服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
+每次调用默认使用全新的 `session-<uuid>` 标识。传入 `--session-id <id>` 可自行命名:该 id 对应的持久化 Session 存在时 runner 会沿用,否则创建;两条路径都要求已组合 `sessionPersistence` 与 `sessionQuery` 服务,因此缺少任一服务的 profile 会显式失败,而不会返回一个历史随进程消失的 id;存活身份还必须已有持久化记录,因为仅注册在内存中的 Agent 不会写入任何内容。标识是不透明的,因此会原样使用调用方给出的字符串,包括空白字符。沿用被限定在当前工作目录内,并拒绝子 agent 或 fork 会话、未记录工作目录的会话、运行在本 profile 不组合的 agent preset 下的会话,以及 preset 记录畸形的会话——该检查读取 Session 日志当前记录的 preset,因此在空白期切换过 preset 的会话同样会被拒绝。因此监督进程无法在另一套组合下悄悄驱动他人的会话;任一不匹配都会在任务运行前失败。
 
 
 ### 机器可读输出
 ### 机器可读输出
 
 

+ 10 - 8
packages/bundle/headless/src/index.ts

@@ -265,23 +265,25 @@ async function resolveAgent(
   if (persistence === undefined) {
   if (persistence === undefined) {
     throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
     throw new Error('headless --session-id requires the sessionPersistence service; the Session would not survive this process')
   }
   }
+  // A later process holds no live Agent and has to find the id through the
+  // query service, so every --session-id run requires it even when this
+  // process already has the identity live.
+  const query = ctx.get('sessionQuery')
+  if (query === undefined) {
+    throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
+  }
   const live = agents.get(sessionId)
   const live = agents.get(sessionId)
   if (live !== undefined) {
   if (live !== undefined) {
     // A live identity skips adoption, not the rules that make adoption safe.
     // A live identity skips adoption, not the rules that make adoption safe.
     assertAdoptable(live.session.header, liveEvents(live.session), sessionId)
     assertAdoptable(live.session.header, liveEvents(live.session), sessionId)
-    // The service can be mounted while this particular Agent was registered in
-    // memory (a custom factory or direct `agents.register`); the backend then
-    // holds no write handle for it and `session/flush` stores nothing. A stored
-    // record proves the id is actually persistence-backed.
+    // A stored record rules out an Agent registered only in memory, whose
+    // `session/flush` would store nothing; write-handle ownership itself is
+    // not queryable through the persistence contract.
     if (await persistence.stat(sessionId) === undefined) {
     if (await persistence.stat(sessionId) === undefined) {
       throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`)
       throw new Error(`live session "${sessionId}" has no persisted record, so the one-shot runner cannot promise it survives this process`)
     }
     }
     return live
     return live
   }
   }
-  const query = ctx.get('sessionQuery')
-  if (query === undefined) {
-    throw new Error('headless --session-id requires the sessionQuery service; dsh-base provides it')
-  }
   try {
   try {
     using observation = await query.observeSession(sessionId)
     using observation = await query.observeSession(sessionId)
     assertAdoptable(observation.header, observation.events, sessionId)
     assertAdoptable(observation.header, observation.events, sessionId)

+ 4 - 1
packages/bundle/headless/src/json-stream.ts

@@ -141,7 +141,10 @@ export function boundJsonLine(
   return JSON.stringify({ type: bounded.type, truncated: true })
   return JSON.stringify({ type: bounded.type, truncated: true })
 }
 }
 
 
-/** Parse raw tool-call arguments as the executor does: empty input is `{}`, invalid JSON stays text. */
+/**
+ * Parse raw tool-call arguments as the executor does: empty input is `{}`,
+ * invalid or non-round-trippable JSON (a non-finite number) stays text.
+ */
 function parseArguments(raw: string): unknown {
 function parseArguments(raw: string): unknown {
   if (raw === '') return {}
   if (raw === '') return {}
   const seen = { nonFinite: false }
   const seen = { nonFinite: false }

+ 21 - 3
packages/bundle/headless/tests/headless.spec.ts

@@ -44,6 +44,8 @@ interface BenchOptions {
   sessionId?: string
   sessionId?: string
   json?: boolean
   json?: boolean
   observe?: () => Promise<ObservationStub>
   observe?: () => Promise<ObservationStub>
+  /** Leave the query service unmounted to exercise the fail-loud path. */
+  omitSessionQuery?: boolean
   /** Leave the persistence service unmounted to exercise the fail-loud path. */
   /** Leave the persistence service unmounted to exercise the fail-loud path. */
   omitPersistence?: boolean
   omitPersistence?: boolean
   /** Mount the service but make it report no stored record for any id. */
   /** Mount the service but make it report no stored record for any id. */
@@ -174,8 +176,9 @@ async function bench(script: Script, options: BenchOptions = {}): Promise<{
       return { agent, dispose: () => Promise.resolve() }
       return { agent, dispose: () => Promise.resolve() }
     },
     },
   })
   })
-  if (options.observe !== undefined) {
-    ctx.provide('sessionQuery', { observeSession: () => options.observe!() } as never)
+  if (options.omitSessionQuery !== true && (options.sessionId !== undefined || options.observe !== undefined)) {
+    const observe = options.observe ?? (() => Promise.reject(new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')))
+    ctx.provide('sessionQuery', { observeSession: () => observe() } as never)
   }
   }
   if (options.omitPersistence !== true) {
   if (options.omitPersistence !== true) {
     ctx.provide('sessionPersistence', {
     ctx.provide('sessionPersistence', {
@@ -670,13 +673,28 @@ describe('headless runner', () => {
   })
   })
 
 
   it('requires the Session query service for an exact Session identity', async () => {
   it('requires the Session query service for an exact Session identity', async () => {
-    const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact' })
+    const test = await bench({ afterPrompt: () => {} }, { sessionId: 'session-exact', omitSessionQuery: true })
     const result = await test.run()
     const result = await test.run()
     expect(result.code).toBe(1)
     expect(result.code).toBe(1)
     expect(result.err).toContain('requires the sessionQuery service')
     expect(result.err).toContain('requires the sessionQuery service')
     await test.ctx.fiber.dispose()
     await test.ctx.fiber.dispose()
   })
   })
 
 
+  it('requires the Session query service even when a live Agent holds the identity', async () => {
+    const test = await bench({
+      afterPrompt(session, message) { appendTurn(session, 1, message, 'live', true) },
+    }, {
+      sessionId: 'session-exact',
+      prelive: true,
+      omitSessionQuery: true,
+    })
+    const result = await test.run()
+    expect(result.code).toBe(1)
+    expect(result.err).toContain('requires the sessionQuery service')
+    expect(result.out).toBe('')
+    await test.ctx.fiber.dispose()
+  })
+
   it('reuses a live Agent already registered under the requested identity', async () => {
   it('reuses a live Agent already registered under the requested identity', async () => {
     const test = await bench({
     const test = await bench({
       afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) },
       afterPrompt(session, message) { appendTurn(session, 1, message, 'live answer', true) },