1
0
Эх сурвалжийг харах

refactor(subagent): consume shared session observations

imccyu 1 сар өмнө
parent
commit
f5f0448bee
48 өөрчлөгдсөн 572 нэмэгдсэн , 302 устгасан
  1. 1 0
      apps/cli/package.json
  2. 10 15
      apps/cli/tests/github-webhook-real.e2e.ts
  3. 3 0
      apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml
  4. 14 0
      apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts
  5. 3 18
      apps/cli/tests/web-agent-presets.e2e.ts
  6. 3 0
      apps/cli/tsconfig.json
  7. 12 0
      apps/web/tests/cordis-tool-round.e2e.ts
  8. 2 2
      apps/web/tests/expected/github-ready-review/conversation.expected.md
  9. 1 1
      apps/web/tests/reference-composer.e2e.ts
  10. 16 25
      apps/web/tests/seeded-history.e2e.ts
  11. 1 1
      apps/web/tests/smoke-real.e2e.ts
  12. 32 0
      apps/web/tests/subagent-conversation.e2e.ts
  13. 9 2
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  14. 1 1
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  15. 0 1
      packages/client/ui-conversation/tests/conversation-registry.client.spec.ts
  16. 0 11
      packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx
  17. 4 1
      packages/client/ui-subagent/src/client/index.ts
  18. 1 0
      packages/client/ui-subagent/tests/browser-plugin.client.spec.ts
  19. 2 2
      packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx
  20. 1 0
      packages/experimental/agent-team/package.json
  21. 2 0
      packages/experimental/agent-team/tests/persistence.spec.ts
  22. 2 0
      packages/experimental/agent-team/tests/team.spec.ts
  23. 14 0
      packages/experimental/agent-team/tests/test-session-query.ts
  24. 1 0
      packages/experimental/tool-agent-team/package.json
  25. 13 0
      packages/experimental/tool-agent-team/tests/tool-team.spec.ts
  26. 20 63
      packages/host/apiproxy/src/api-proxy.ts
  27. 1 1
      packages/host/apiproxy/src/api/index.ts
  28. 10 0
      packages/host/apiproxy/src/api/llm.schema.ts
  29. 2 3
      packages/host/apiproxy/src/api/llm.ts
  30. 35 3
      packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
  31. 2 0
      packages/host/apiproxy/tests/api-proxy-config.spec.ts
  32. 20 10
      packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts
  33. 4 3
      packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
  34. 21 3
      packages/host/apiproxy/tests/client-handler.spec.ts
  35. 12 1
      packages/host/apiproxy/tests/fetch-carrier.spec.ts
  36. 5 0
      packages/subagent/subagent/package.json
  37. 26 8
      packages/subagent/subagent/src/continuation.ts
  38. 7 18
      packages/subagent/subagent/src/index.ts
  39. 76 69
      packages/subagent/subagent/src/list-children.ts
  40. 2 0
      packages/subagent/subagent/tests/continuation-inheritance.spec.ts
  41. 18 2
      packages/subagent/subagent/tests/continuation.spec.ts
  42. 127 38
      packages/subagent/subagent/tests/list-children.spec.ts
  43. 14 0
      packages/subagent/subagent/tests/test-session-query.ts
  44. 3 0
      packages/subagent/subagent/tsconfig.json
  45. 1 0
      packages/subagent/tool-subagent-control/package.json
  46. 2 0
      packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
  47. 14 0
      packages/subagent/tool-subagent-control/tests/test-session-query.ts
  48. 2 0
      packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts

+ 1 - 0
apps/cli/package.json

@@ -124,6 +124,7 @@
     "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
     "@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-settings": "workspace:^",
     "@deepseek-ai/dsh-settings-file": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",

+ 10 - 15
apps/cli/tests/github-webhook-real.e2e.ts

@@ -28,8 +28,8 @@ interface SessionList {
   items: Array<{
     sessionId: string
     cwd?: string
-    agentPreset?: string
     blank: boolean
+    projections?: { values: { agentPreset?: string | null } }
   }>
 }
 
@@ -222,23 +222,18 @@ async function workspaceBaseline(baseUrl: string): Promise<WorkspaceBaseline> {
   return frame.value as WorkspaceBaseline
 }
 
-/** Read the explicit page cut from a fresh Session follow generation. */
-async function sessionCursor(baseUrl: string, sessionId: string): Promise<number> {
+/** Read the complete opening page from a fresh Session follow generation. */
+async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
   const frame = await openingStreamItem(
     baseUrl,
     'session/follow',
-    { request: { address: { kind: 'session', sessionId } } },
-    value => isRecord(value) && value.type === 'opened' && Number.isSafeInteger(value.cursor),
+    { request: { address: { kind: 'session', sessionId }, maxMessages: 100 } },
+    value => isRecord(value)
+      && value.type === 'snapshot'
+      && Array.isArray(value.events)
+      && typeof value.hasMore === 'boolean',
   )
-  return frame.cursor as number
-}
-
-/** Read Session history at the cursor explicitly opened for this page. */
-async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
-  const throughSeq = await sessionCursor(baseUrl, sessionId)
-  return remoteRpc<HistoryPage>(baseUrl, 'session/page', {
-    request: { address: { kind: 'session', sessionId }, throughSeq, maxMessages: 100 },
-  })
+  return { events: frame.events as HistoryPage['events'], hasMore: frame.hasMore as boolean }
 }
 
 /** Poll a public observation until it satisfies the test's behavior predicate. */
@@ -379,9 +374,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('GitHub webhook through the real
 
       const sessions = await remoteRpc<SessionList>(baseUrl, 'session/list', { _request: {} })
       expect(sessions.items.find(session => session.sessionId === sessionId)).toMatchObject({
-        agentPreset: 'minimal',
         blank: false,
         cwd: canonicalWorkspacePath,
+        projections: { values: { agentPreset: 'minimal' } },
       })
 
       const admitted = await eventually(

+ 3 - 0
apps/cli/tests/profiles/headless/subagent-diagnostic.cordis.snapshot.yml

@@ -10,6 +10,9 @@
     root: './.sessions'
     compression: none
 
+- id: session-query
+  name: './tests/fixtures/subagent-diagnostic-query.ts'
+
 # file/override both default to their DSH_SNAPSHOT_* env vars.
 - id: replay
   name: '@deepseek-ai/dsh-llm-replay'

+ 14 - 0
apps/cli/tests/profiles/headless/tests/fixtures/subagent-diagnostic-query.ts

@@ -0,0 +1,14 @@
+/** Exact-read Session query used by the descriptor-less child snapshot. */
+
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+
+/** Search is outside this fixture; inherited corpus and observation reads stay real. */
+export default class SubagentDiagnosticQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is unavailable in this fixture'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is unavailable in this fixture'))
+  }
+}

+ 3 - 18
apps/cli/tests/web-agent-presets.e2e.ts

@@ -12,7 +12,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
 import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
 import { SUBAGENT_MODEL_SELECTION_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-tool-subagent/model-selection-settings'
-import { resolveSessionPreset, SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
+import { SETTINGS_NAMESPACE, SHIPPED_PRESET_ROOT } from '@deepseek-ai/dsh-agent-presets'
 import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent'
 import { CallId } from '@deepseek-ai/dsh-llm'
 import type {} from '@deepseek-ai/dsh-compaction-basic'
@@ -629,27 +629,12 @@ describe('a switch survives the session', () => {
 
       // The header keeps the creation fact; the log carries what it runs.
       expect(handle.agent.session.header.agentPreset).toBe('standard')
-      expect(resolveSessionPreset(handle.agent.session)).toBe('minimal')
+      expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal')
     } finally {
       await handle.dispose()
     }
   })
 
-  it('rebuilds a switched session from the log, not the creation header', () => {
-    // The exact shape a resume reads back from disk: the header says standard,
-    // the log records the switch the user made while the session was blank.
-    const rebuilt = resolveSessionPreset({
-      header: { version: 0, id: SessionId('x'), createdAt: 0, agentPreset: 'standard' },
-      events: [
-        { type: 'agent-preset/selected', seq: 1, time: 0, data: { agentPreset: 'minimal' } },
-        { type: 'turn/start', seq: 2, time: 0, data: { turn: 0, trigger: { kind: 'message', source: { kind: 'user' } } } },
-      ] as never,
-    })
-
-    // Reading the header alone would compose the creation-time preset over a
-    // history another one produced — the replay the blank-only lock prevents.
-    expect(rebuilt).toBe('minimal')
-  })
 })
 
 describe('a forked session', () => {
@@ -659,7 +644,7 @@ describe('a forked session', () => {
       meta: { agentPreset: 'minimal' },
       setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined),
     })
-    const inherited = resolveSessionPreset(parent.agent.session)
+    const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined
     const child = await ctx.agents.create({
       sessionId: SessionId('preset-fork-child'),
       meta: {

+ 3 - 0
apps/cli/tsconfig.json

@@ -65,6 +65,9 @@
     {
       "path": "../../packages/session-query/session-query-sqlite"
     },
+    {
+      "path": "../../packages/session-query/session-query"
+    },
     {
       "path": "../../packages/shell/shell-env"
     },

+ 12 - 0
apps/web/tests/cordis-tool-round.e2e.ts

@@ -69,6 +69,8 @@ describe('web e2e: Cordis tools use their owned cards', () => {
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
   const sessionEvents: SessionEvent[] = []
+  const modelFrames: string[] = []
+  const modelChanges: string[] = []
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({
@@ -77,8 +79,17 @@ describe('web e2e: Cordis tools use their owned cards', () => {
       ...(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: 15 }),
     })
     scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
+    scaffold.ctx.sessionProjections.onChanged((_session, key, value, seq) => {
+      if (key === 'modelSelection') modelChanges.push(`${String(seq)}:${JSON.stringify(value)}`)
+    })
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    page.on('websocket', (socket) => {
+      socket.on('framereceived', (frame) => {
+        const payload = String(frame.payload)
+        if (payload.includes('modelSelection')) modelFrames.push(payload)
+      })
+    })
     tripwire = watchConsole(page)
     await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
     await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
@@ -169,6 +180,7 @@ describe('web e2e: Cordis tools use their owned cards', () => {
 
   it.skipIf(MODE === 'record')('matches the conversation aria golden', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-cordis-aria'))
+    console.log('MODEL_TRACE', { modelChanges, frameCount: modelFrames.length, modelFrames })
     await page.locator('[data-conversation-scroll]').evaluate((host) => { host.scrollTop = host.scrollHeight })
     await expect.poll(
       async () => page.getByRole('button', { name: 'Back to bottom', exact: true }).count(),

+ 2 - 2
apps/web/tests/expected/github-ready-review/conversation.expected.md

@@ -42,8 +42,8 @@
 - button "Commands":
   - img
 - 'button "Access mode, current: Read Only"': Read Only
-- button "Select model":
-  - text: Select model
+- button "Select model, current github-webhook-review-test/reply":
+  - text: github-webhook-review-test/reply
   - img
 - button "Send message" [disabled]
 - text: 1 turns · 1 steps LLM {{duration}}

+ 1 - 1
apps/web/tests/reference-composer.e2e.ts

@@ -233,7 +233,7 @@ describe.skipIf(MODE === 'record')('web e2e: file and session references through
     const group = page.getByRole('treeitem', { name: /Ungrouped/ })
     await group.waitFor({ timeout: 15_000 })
     if (await group.getAttribute('aria-expanded') !== 'true') await group.click()
-    const target = page.getByRole('treeitem').filter({ hasText: /^dsh-web-e2e-ws-/ }).first()
+    const target = page.getByRole('treeitem', { name: /Reference order target/ })
     await target.waitFor({ timeout: 15_000 })
     await target.click()
     await page.getByRole('button', { name: /^Session recall\s*Research notes$/ }).waitFor({ timeout: 15_000 })

+ 16 - 25
apps/web/tests/seeded-history.e2e.ts

@@ -228,44 +228,35 @@ describe('web e2e: seeded history renders through cold resume', () => {
     await recordFixture(scaffold, sessionId, SEED)
   }, 200_000)
 
-  it.skipIf(MODE === 'record')('serves the projections baseline on the real composition tail page', async () => {
+  it.skipIf(MODE === 'record')('serves the projections baseline on the real composition opening snapshot', async () => {
     // Composition regression tripwire: the projection registry must be a row
     // in the SHIPPED cordis.yml — with it absent every domain unit's optional
     // injection stays silent and this block disappears (no titles/todos on
     // the web), while fixture-level suites stay green. Assert through the
-    // real HTTP wire against the booted real host.
-    const response = await fetch(`${scaffold.baseUrl}/api/session/page`, {
-      method: 'POST',
-      headers: { 'content-type': 'application/json' },
-      body: JSON.stringify({
-        type: 'client-request', rpcId: 'seeded-projections', method: 'session/page',
-        payload: {
-          args: { request: {
-            address: { kind: 'session', sessionId: SEED_ID },
-            throughSeq: seededThroughSeq,
-          } },
-        },
-      }),
-    })
-    expect(response.ok).toBe(true)
-    const body = await response.json() as {
-      result: { ok: boolean; value?: { projections?: { asOfSeq: number; values: Record<string, unknown> } } }
+    // production Session Controller against the booted real host.
+    const controller = new AbortController()
+    const stream = scaffold.ctx.sessionController.follow({
+      address: { kind: 'session', sessionId: SessionId(SEED_ID) },
+    }, controller.signal)[Symbol.asyncIterator]()
+    const first = await stream.next()
+    controller.abort()
+    if (first.done || first.value.type !== 'snapshot') {
+      throw new Error('session follow did not publish its opening snapshot')
     }
-    expect(body.result.ok).toBe(true)
-    const projections = body.result.value?.projections
-    expect(projections).toBeDefined()
-    expect(projections?.asOfSeq).toBeGreaterThanOrEqual(0)
+    expect(first.value.cursor).toBe(seededThroughSeq)
+    const projections = first.value.projections
+    expect(projections.asOfSeq).toBe(seededThroughSeq)
     // The seed carries a session/title event: the title unit is host-plane, so
     // it folds the detached log and serves the value with nothing composed.
-    expect(typeof projections?.values.title).toBe('string')
+    expect(typeof projections.values.title).toBe('string')
     // `todos` is absent because its unit belongs to the agent preset and this
     // directly seeded session never composed that preset. History computes
     // the baseline through the standard projection registry without mounting
     // an Agent composition as a read side effect.
-    expect(projections?.values).not.toHaveProperty('todos')
+    expect(projections.values).not.toHaveProperty('todos')
     // The session-stats unit is a shipped web-app bundle row: whole-log
     // turn/step counts ride the same tail block (the stats strip's source).
-    const sessionStats = projections?.values.sessionStats as { turns: number; steps: number } | undefined
+    const sessionStats = projections.values.sessionStats as { turns: number; steps: number } | undefined
     expect(sessionStats).toBeDefined()
     expect(sessionStats?.turns).toBeGreaterThanOrEqual(1)
     expect(sessionStats?.steps).toBeGreaterThanOrEqual(sessionStats?.turns ?? 0)

+ 1 - 1
apps/web/tests/smoke-real.e2e.ts

@@ -125,7 +125,7 @@ async function sessionCursor(baseUrl: string, sessionId: string): Promise<number
           }
           const value = frame.value
           if (frame.type === 'item' && isRecord(value)
-            && value.type === 'opened' && Number.isSafeInteger(value.cursor)) {
+            && value.type === 'snapshot' && Number.isSafeInteger(value.cursor)) {
             finish(undefined, value.cursor as number)
           }
         } catch (error) {

+ 32 - 0
apps/web/tests/subagent-conversation.e2e.ts

@@ -353,6 +353,38 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
     await compareOrRefreshGolden(SIDEBAR_EXPECTED, sidebar, MODE)
   })
 
+  it('keeps a restored child neutral until its parent availability arrives', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-restore'))
+    const pattern = '**/api/subagent.list'
+    let requested = false
+    let releaseCatalog = (): void => {}
+    const catalogHeld = new Promise<void>((resolve) => { releaseCatalog = resolve })
+    await page.route(pattern, async (route) => {
+      const response = await route.fetch()
+      requested = true
+      await catalogHeld
+      await route.fulfill({ response })
+    })
+
+    const warningStart = tripwire.warnings.length
+    try {
+      await page.reload({ waitUntil: 'load' })
+      await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+      await expect.poll(() => requested, { timeout: 15_000 }).toBe(true)
+      expect(await page.getByText('This subagent is read-only for now', { exact: true }).count()).toBe(0)
+      expect(await page.locator('[data-composer-seat]').evaluate(element =>
+        getComputedStyle(element).visibility)).toBe('hidden')
+      releaseCatalog()
+      const input = page.getByRole('textbox', { name: 'Message the agent' })
+      await input.waitFor({ timeout: 15_000 })
+      await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
+      acknowledgeReloadConnectionLoss(tripwire, warningStart)
+    } finally {
+      releaseCatalog()
+      await page.unroute(pattern)
+    }
+  })
+
   it('continues through FIFO follow-up admission and receives the child follow events', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-followup'))
     const ended = new Promise<void>((resolveEnded, reject) => {

+ 9 - 2
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -80,8 +80,15 @@ export function ConversationRoot({
   // The exemption is deliberately open-state-wide, not loading-only: a
   // summary-blank session is the hero before its open starts (`cold`) and
   // after one fails (`error`) for the same reason — there is no history.
-  const settling = sessionId !== undefined && shellPhase === 'blank' && openState === 'loading'
-    && summaryBlank !== true
+  // A restored continuable subagent also stays settled until its eagerly
+  // loaded parent catalog establishes availability. This keeps the composer
+  // hidden instead of briefly rendering the parent-offline takeover.
+  const parentAvailabilityPending = session?.subagent?.address.mode === 'continuable'
+    && session.subagent.parentAvailable === undefined
+  const settling = sessionId !== undefined && (
+    (shellPhase === 'blank' && openState === 'loading' && summaryBlank !== true)
+    || parentAvailabilityPending
+  )
   const hero = sessionId === undefined
     || (shellPhase === 'blank' && (openState === 'open' || summaryBlank === true))
   const zone: InputZone | undefined =

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -157,7 +157,7 @@ export function InputBar({
   // A continuable child without its live parent cannot accept human input,
   // but its independent Stop below stays available while it runs.
   const continuable = subagent?.address.mode === 'continuable'
-  const parentOffline = continuable && !subagent.parentAvailable
+  const parentOffline = continuable && subagent.parentAvailable !== true
   // Running input stays free; locked = session removed, the
   // inert no-workspace state, the machine faces absent (no session), or a
   // parent-offline continuable child. An owner block also disables input;

+ 0 - 1
packages/client/ui-conversation/tests/conversation-registry.client.spec.ts

@@ -79,7 +79,6 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind
     subagentAddress: () => undefined,
     setSubagentCatalogOpen: () => {},
     refreshSubagents: () => Promise.reject(new Error('unused fake Sessions operation')),
-    noteAgentPreset: () => {},
     clear: () => {},
     refresh: () => Promise.reject(new Error('unused fake Sessions operation')),
     search: () => Promise.reject(new Error('unused fake Sessions operation')),

+ 0 - 11
packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx

@@ -499,7 +499,6 @@ function CatalogDropdown({
   const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
   const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
   const observedCatalogs = useRef(new Set<SessionId>())
-  const requestedInitialCatalog = useRef<SessionId>()
   const setCatalogOpenRef = useRef(setCatalogOpen)
   setCatalogOpenRef.current = setCatalogOpen
   const currentEntry = currentSessionId === undefined
@@ -531,16 +530,6 @@ function CatalogDropdown({
     }
     : catalog
 
-  useEffect(() => {
-    if (
-      variant !== 'switcher'
-      || catalog !== undefined
-      || requestedInitialCatalog.current === rootSessionId
-    ) return
-    requestedInitialCatalog.current = rootSessionId
-    refresh(rootSessionId)
-  }, [catalog, refresh, rootSessionId, variant])
-
   const observeCatalog = (parentSessionId: SessionId, next: boolean): void => {
     if (next) observedCatalogs.current.add(parentSessionId)
     else observedCatalogs.current.delete(parentSessionId)

+ 4 - 1
packages/client/ui-subagent/src/client/index.ts

@@ -34,7 +34,10 @@ function selectReadOnlySubagent(owner: ComposerChainProps): SubagentReadOnlyMatc
   const subagent = owner.session?.subagent
   if (subagent === undefined || subagent === null) return null
   if (subagent.address.mode === 'one-shot') return { reason: 'one-shot' }
-  if (subagent.parentAvailable) return null
+  // The parent catalog is fetched ahead of the selected Session. Until it
+  // resolves, leave the normal disabled composer in place instead of briefly
+  // claiming that the parent is offline.
+  if (subagent.parentAvailable !== false) return null
   // A RUNNING parent-offline continuable child keeps the default composer:
   // its input is disabled there, but the same primary Stop stays available so
   // the child can be interrupted. Once it stops, this takeover returns.

+ 1 - 0
packages/client/ui-subagent/tests/browser-plugin.client.spec.ts

@@ -133,6 +133,7 @@ describe('apply', () => {
     // One-shot stays read-only even while running: it has no stop action.
     expect(select(owner({ address: { ...address, mode: 'one-shot' }, parentAvailable: true }, true)))
       .toEqual({ reason: 'one-shot' })
+    expect(select(owner({ address }))).toBeNull()
     expect(select(owner({ address, parentAvailable: true }))).toBeNull()
     expect(select(owner({ address, parentAvailable: false })))
       .toEqual({ reason: 'parent-unavailable' })

+ 2 - 2
packages/client/ui-subagent/tests/conversation-ui.client.spec.tsx

@@ -770,7 +770,7 @@ describe('SubagentHeaderLineage', () => {
   it.each([
     ['ancestor', vi.fn()],
     ['current', undefined],
-  ] as const)('refreshes an absent %s switcher catalog without waiting for hover', (_kind, openTitle) => {
+  ] as const)('keeps an absent %s switcher catalog lazy until interaction', (_kind, openTitle) => {
     const input = {
       ...props(undefined, {}, {
         [CHILD]: {
@@ -784,7 +784,7 @@ describe('SubagentHeaderLineage', () => {
     }
     render(<SubagentHeaderLineage {...input} />)
 
-    expect(input.refresh).toHaveBeenCalledWith(PARENT)
+    expect(input.refresh).not.toHaveBeenCalled()
   })
 
   it('keeps a nested title switcher scoped to its direct-parent catalog', () => {

+ 1 - 0
packages/experimental/agent-team/package.json

@@ -54,6 +54,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
     "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",

+ 2 - 0
packages/experimental/agent-team/tests/persistence.spec.ts

@@ -16,6 +16,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import TeamService, { foldTeam, TeamId, TeamMessageId } from '../src/index.ts'
 import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 const SIGNAL = new AbortController().signal
 const PERSISTENCE_TEST_TIMEOUT_MS = 15_000
@@ -94,6 +95,7 @@ async function stack(
   contexts.add(ctx)
   await mountAgentLoopTestDependencies(ctx)
   await backend.mount(ctx, root)
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })

+ 2 - 0
packages/experimental/agent-team/tests/team.spec.ts

@@ -16,6 +16,7 @@ import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-a
 import TeamService, { foldTeam, TeamError, TeamId, TeamMessageId, TeamTaskId } from '../src/index.ts'
 import { TeamRuntimeLifecycle } from '../src/lifecycle.ts'
 import type { TeamMemberSnapshot, TeamMessageSnapshot, TeamTaskSnapshot } from '../src/index.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 const SIGNAL = new AbortController().signal
 const roots: string[] = []
@@ -48,6 +49,7 @@ async function setup(
   const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-team-'))
   roots.push(storageRoot)
   await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })

+ 14 - 0
packages/experimental/agent-team/tests/test-session-query.ts

@@ -0,0 +1,14 @@
+/** Minimal concrete Session query for Agent Team continuation tests. */
+
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+
+/** Session query implementation whose search faces are outside these tests. */
+export class TestSessionQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is not configured in this test'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}

+ 1 - 0
packages/experimental/tool-agent-team/package.json

@@ -48,6 +48,7 @@
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-scope": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",

+ 13 - 0
packages/experimental/tool-agent-team/tests/tool-team.spec.ts

@@ -10,6 +10,7 @@ import { CallId } from '@deepseek-ai/dsh-llm'
 import { scopeOf } from '@deepseek-ai/dsh-scope'
 import { SessionId } from '@deepseek-ai/dsh-session'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
 import SubagentService from '@deepseek-ai/dsh-subagent'
 import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
@@ -37,6 +38,17 @@ const TOOL_NAMES = [
 const roots: string[] = []
 let callNumber = 0
 
+/** Session query implementation whose search faces are outside these tests. */
+class TestSessionQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is not configured in this test'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}
+
 afterEach(() => {
   for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
 })
@@ -47,6 +59,7 @@ async function setup(script: ConstructorParameters<typeof MockAdapter>[0], legac
   const storageRoot = mkdtempSync(join(tmpdir(), 'dsh-tool-team-'))
   roots.push(storageRoot)
   await ctx.plugin(JsonlSessionPersistence, { root: storageRoot })
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SubagentService)
   if (legacyControl) await ctx.plugin(ToolSubagentControl)

+ 20 - 63
packages/host/apiproxy/src/api-proxy.ts

@@ -10,19 +10,18 @@ import type { Agent, ModelSelection } from '@deepseek-ai/dsh-agent'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import type { Session, SessionId } from '@deepseek-ai/dsh-session'
 import { SubagentError } from '@deepseek-ai/dsh-subagent'
-import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
 import { isUserInvocable } from '@deepseek-ai/dsh-skill'
 import {
   InvalidPresetIdError, PresetExistsError, PresetMountError,
-  PresetNotWritableError, resolveSessionPreset, UnknownPresetError,
+  PresetNotWritableError, UnknownPresetError,
 } from '@deepseek-ai/dsh-agent-presets'
-import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
 import type {
   ApiProxy, ConfigurableProviderView, CredentialView, GoalRef,
-  SettingsNamespaceView, SubagentAddress,
+  SettingsNamespaceView,
 } from './api/index.ts'
 import type { SessionRequestId } from '@deepseek-ai/dsh-api-session-controller/types'
-import { ApiSessionNotFound, buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
+import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
+import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
 import {
   DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
   flushLiveSessionLog,
@@ -199,49 +198,6 @@ function projectionsUnavailableError(): RpcError {
   }
 }
 
-/** Verify one address and mode against the complete direct-child catalog. */
-async function catalogChild(
-  ctx: Context,
-  address: SubagentAddress,
-  signal?: AbortSignal,
-): Promise<{
-  entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
-  error?: RpcError
-}> {
-  const { parentSessionId, childSessionId, mode } = address
-  try {
-    const entries = await ctx.subagents.listChildren(parentSessionId, signal)
-    const entry = entries.find(candidate => candidate.id === childSessionId)
-    if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
-      return {
-        error: {
-          code: 'subagent-not-found',
-          message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
-          details: { parentSessionId, childSessionId },
-        },
-      }
-    }
-    if (entry.kind === 'diagnostic') {
-      return {
-        error: {
-          code: 'subagent-catalog-diagnostic',
-          message: `subagent "${childSessionId}" is ${entry.reason}`,
-          details: { parentSessionId, childSessionId, reason: entry.reason },
-        },
-      }
-    }
-    return { entry }
-  } catch (error: unknown) {
-    if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
-      return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
-    }
-    if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
-      return { error: projectionsUnavailableError() }
-    }
-    return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
-  }
-}
-
 /**
  * The requested preset differs from the one this session already runs.
  *
@@ -300,14 +256,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
   /** Resolve a Session's live or standing preset scope without resuming it. */
   async function sessionScopeFor(
     sessionId: SessionId,
-    session: PresetBearingSession,
+    agentPreset: string | undefined,
   ): Promise<ScopeKey | undefined> {
     const live = ctx.get('agents')?.get(sessionId)
     if (live !== undefined) return live
     const presets = ctx.get('agentPresets')
     if (presets === undefined) return undefined
     try {
-      return await presets.standingKeyFor(resolveSessionPreset(session))
+      return await presets.standingKeyFor(agentPreset)
     } catch {
       // An unknown or unusable recorded preset falls back to the global registry.
       return undefined
@@ -536,10 +492,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             details: { parentSessionId },
           })
         }
-        const verified = await catalogChild(ctx, {
-          parentSessionId, childSessionId, mode: 'continuable',
-        }, signal)
-        if (verified.error !== undefined) return err(request, verified.error)
         try {
           const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
             source: {
@@ -869,15 +821,21 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       // the view scope is the live agent or the preset's standing key.
       async list(request) {
         const { sessionId } = request.payload
-        let session: PresetBearingSession
+        let cwd: string | undefined
+        let agentPreset: string | undefined
         try {
-          const inspected = await ctx.sessionController.inspect(sessionId)
-          session = { header: inspected.meta, events: inspected.events }
+          using observation = await ctx.sessionQuery.observeSession(sessionId)
+          if (observation.projections === undefined) {
+            throw new Error('skill catalog requires a projected Session observation')
+          }
+          cwd = observation.header.cwd
+          agentPreset = observation.projections.values.agentPreset ?? undefined
         } catch (error: unknown) {
-          if (error instanceof ApiSessionNotFound) {
+          if (error instanceof SessionQueryError
+            && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
             return err(request, {
               code: 'session-not-found',
-              message: error.message,
+              message: `session "${sessionId}" not found`,
               details: { sessionId },
             })
           }
@@ -887,12 +845,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             details: {},
           })
         }
-        if (session.header.cwd === undefined) {
+        if (cwd === undefined) {
           // Every served session records its project at create time; a
           // cwd-less header is a pre-project legacy log (not served).
           return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
         }
-        const cwd = session.header.cwd
         // The host registry is layered per scope and serves every session. A
         // composition may still realm-mount its own registry instead; that
         // instance is invisible to host contexts, so address it through the
@@ -910,7 +867,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         }
         // Resolve the live or recorded preset scope so the catalog matches the
         // Session composition without resuming its Agent.
-        const scope = await sessionScopeFor(sessionId, session)
+        const scope = await sessionScopeFor(sessionId, agentPreset)
         try {
           const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
           return ok(request, {
@@ -1065,7 +1022,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       },
 
       async models(request) {
-        return ok(request, await buildModelCatalog(ctx))
+        return ok(request, await buildModelCatalog(ctx, defaults.defaultModelSelection()))
       },
 
       async discoverModels(request, signal) {

+ 1 - 1
packages/host/apiproxy/src/api/index.ts

@@ -30,7 +30,7 @@ export interface ApiProxy {
 
 // ---- Domain interfaces and payload entities ----
 export type {
-  ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
+  ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
   ModelReasoningEffort, ModelSelection,
 } from '@deepseek-ai/dsh-api-session-controller/types'
 export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'

+ 10 - 0
packages/host/apiproxy/src/api/llm.schema.ts

@@ -10,6 +10,7 @@ import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
 import type {
   ModelCatalogFailure,
   ModelCatalogModel,
+  ModelSelection,
   ModelProviderGroup,
   ModelReasoning,
   ModelReasoningEffort,
@@ -50,6 +51,13 @@ const modelCatalogFailureSchema = z.object({
   message: z.string(),
 }) satisfies z.ZodType<Wire<ModelCatalogFailure>>
 
+/** Complete model selection used as the Host default. */
+const modelSelectionSchema = z.object({
+  provider: z.string().min(1),
+  model: z.string().min(1),
+  reasoningEffort: z.string().min(1).optional(),
+}) satisfies z.ZodType<Wire<ModelSelection>>
+
 /** ConfigurableProviderView row of llm.providers. */
 export const configurableProviderViewSchema = z.object({
   provider: z.string().min(1),
@@ -73,6 +81,8 @@ export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType<Wire<Requ
 
 /** llm.models response value. */
 export const llmModelsValueSchema = z.object({
+  default: modelSelectionSchema,
+  routableProviders: z.array(z.string().min(1)),
   groups: z.array(modelProviderGroupSchema),
   failures: z.array(modelCatalogFailureSchema),
 }) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>

+ 2 - 3
packages/host/apiproxy/src/api/llm.ts

@@ -9,8 +9,7 @@
 
 import type { RpcRequest, RpcResponse } from './rpc.ts'
 import type {
-  ModelCatalogFailure,
-  ModelProviderGroup,
+  ModelCatalog,
 } from '@deepseek-ai/dsh-api-session-controller/types'
 
 /** Wire view of one configurable provider. */
@@ -48,7 +47,7 @@ export interface LlmApi {
    * settings surface's models view, needing no session. Per-provider listing
    * failures ride `failures` without failing the sound groups.
    */
-  models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
+  models(request: RpcRequest<{}>): Promise<RpcResponse<ModelCatalog>>
 
   /**
    * Interrogate a provider endpoint the configuration surface is still

+ 35 - 3
packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts

@@ -10,12 +10,13 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
 import { RpcId, type RpcRequest } from '../src/api/rpc.ts'
 import type { ApiProxy } from '../src/api/index.ts'
 import {
-  InvalidPresetIdError, PresetExistsError, resolveSessionPreset, UnknownPresetError,
+  agentPresetProjectionDefinition, InvalidPresetIdError, PresetExistsError, UnknownPresetError,
 } from '@deepseek-ai/dsh-agent-presets'
 import type {} from '@deepseek-ai/dsh-agent-presets/types'
 import { GoalId } from '@deepseek-ai/dsh-goal'
 import { createApiProxy } from '../src/api-proxy.ts'
 import { describe, expect, it } from 'vitest'
+import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 
 let nextRpc = 0
 function request<P>(payload: P): RpcRequest<P> {
@@ -122,6 +123,35 @@ async function harness(
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
   if (presets !== undefined) ctx.provide('agentPresets', roster(presets, options.userIds) as never)
+  ctx.provide('sessionQuery', {
+    observeSession: (sessionId: SessionId) => {
+      const session = ctx.sessions.get(sessionId)
+      if (session === undefined) {
+        return Promise.reject(new SessionQueryError(
+          `session "${sessionId}" not found`,
+          'SESSION_QUERY_SESSION_NOT_FOUND',
+        ))
+      }
+      let preset = agentPresetProjectionDefinition.init(session.header)
+      for (const event of session.events) {
+        preset = agentPresetProjectionDefinition.apply(preset, event)
+      }
+      const events = Object.freeze([...session.events])
+      const lease = (): SessionObservation => ({
+        source: 'live' as const,
+        header: session.header,
+        events,
+        cursor: events.at(-1)?.seq ?? -1,
+        projections: {
+          asOfSeq: events.at(-1)?.seq ?? -1,
+          values: { agentPreset: preset },
+        },
+        retain: lease,
+        [Symbol.dispose]: () => {},
+      })
+      return Promise.resolve(lease())
+    },
+  } as never)
 
   const factory: AgentFactory = {
     async createAgent(_ownerCtx, options) {
@@ -289,7 +319,8 @@ describe('agentPreset.select', () => {
     const session = ctx.sessions.get(SessionId('sel-log'))
     if (session === undefined) throw new Error('unreachable')
     expect(session.header.agentPreset).toBe('standard')
-    expect(resolveSessionPreset(session)).toBe('minimal')
+    expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
+      .toEqual({ agentPreset: 'minimal' })
   })
 
   it('serializes two concurrent selects on one session', async () => {
@@ -309,7 +340,8 @@ describe('agentPreset.select', () => {
     const session = ctx.sessions.get(SessionId('sel-race'))
     if (session === undefined) throw new Error('unreachable')
     // One winner, and the log agrees with it: the last committed switch.
-    expect(resolveSessionPreset(session)).toBe('standard')
+    expect(session.events.findLast(event => event.type === 'agent-preset/selected')?.data)
+      .toEqual({ agentPreset: 'standard' })
   })
 
   it('refuses once the conversation has started', async () => {

+ 2 - 0
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -679,6 +679,8 @@ describe('llm domain', () => {
     ctx.llm.registerAdapter(['broken'], new BrokenCatalogAdapter('Broken', []))
     const api = createApiProxy(ctx, DEFAULTS)
     const value = expectOk(await api.llm.models(request({})))
+    expect(value.default).toEqual({ provider: 'p', model: 'm' })
+    expect(value.routableProviders).toEqual(['deepseek-official', 'broken'])
     expect(value.groups).toEqual([{
       id: 'deepseek-official',
       name: 'DeepSeek',

+ 20 - 10
packages/host/apiproxy/tests/api-proxy-skills-cold.spec.ts

@@ -1,7 +1,7 @@
 import { Context } from '@deepseek-ai/cordis'
 import AgentRegistry from '@deepseek-ai/dsh-agent'
-import { ApiSessionNotFound } from '@deepseek-ai/dsh-api-session-controller'
 import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import type {} from '@deepseek-ai/dsh-skill'
 import { describe, expect, it, vi } from 'vitest'
 import { createApiProxy } from '../src/api-proxy.ts'
@@ -14,11 +14,18 @@ describe('skill catalog Session inspection', () => {
     await ctx.plugin(AgentRegistry)
     const sessionId = SessionId('cold-skills')
     const resolveAgent = vi.fn()
-    const inspect = vi.fn(() => Promise.resolve({
-      meta: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' },
+    const dispose = vi.fn()
+    const observeSession = vi.fn(() => Promise.resolve({
+      source: 'live',
+      header: { version: 0 as const, id: sessionId, createdAt: 1, cwd: '/cold/project' },
       events: [],
-    }))
-    ctx.provide('sessionController', { inspect, resolveAgent } as never)
+      cursor: -1,
+      projections: { asOfSeq: -1, values: {} },
+      retain: () => { throw new Error('not retained') },
+      [Symbol.dispose]: dispose,
+    } satisfies SessionObservation))
+    ctx.provide('sessionQuery', { observeSession } as never)
+    ctx.provide('sessionController', { resolveAgent } as never)
     const list = vi.fn(() => Promise.resolve([{
       name: 'review',
       description: 'Review the current change.',
@@ -42,7 +49,8 @@ describe('skill catalog Session inspection', () => {
         }],
       },
     })
-    expect(inspect).toHaveBeenCalledWith(sessionId)
+    expect(observeSession).toHaveBeenCalledWith(sessionId)
+    expect(dispose).toHaveBeenCalledOnce()
     expect(resolveAgent).not.toHaveBeenCalled()
     expect(list).toHaveBeenCalledWith({ cwd: '/cold/project', scope: undefined })
   })
@@ -51,7 +59,10 @@ describe('skill catalog Session inspection', () => {
     const sessionId = SessionId('missing-skills')
     for (const fixture of [
       {
-        error: new ApiSessionNotFound('session "missing-skills" not found'),
+        error: new SessionQueryError(
+          'session "missing-skills" not found',
+          'SESSION_QUERY_SESSION_NOT_FOUND',
+        ),
         code: 'session-not-found',
       },
       { error: new Error('storage offline'), code: 'internal' },
@@ -59,9 +70,8 @@ describe('skill catalog Session inspection', () => {
       const ctx = new Context()
       await ctx.plugin(SessionStore)
       await ctx.plugin(AgentRegistry)
-      ctx.provide('sessionController', {
-        inspect: () => Promise.reject(fixture.error),
-        resolveAgent: vi.fn(),
+      ctx.provide('sessionQuery', {
+        observeSession: () => Promise.reject(fixture.error),
       } as never)
       ctx.provide('skills', { list: vi.fn() } as never)
       const api = createApiProxy(ctx, {

+ 4 - 3
packages/host/apiproxy/tests/api-proxy-subagents.spec.ts

@@ -107,7 +107,7 @@ describe('subagent gateway', () => {
       .toMatchObject({ ok: true, value: { entries: [{ activity: 'running' }] } })
   })
 
-  it('maps the missing projections capability to one wire face on list and prompt', async () => {
+  it('maps missing catalog projections on list without preflighting prompt delivery', async () => {
     const listError = () => new SubagentError(
       'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
       'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
@@ -124,8 +124,9 @@ describe('subagent gateway', () => {
     const prompt = bench({ listError: listError() })
     expect((await prompt.api.subagents.prompt(request({
       parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
-    }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
-    expect(prompt.followup).not.toHaveBeenCalled()
+    }), new AbortController().signal)).result).toMatchObject({ ok: true })
+    expect(prompt.listChildren).not.toHaveBeenCalled()
+    expect(prompt.followup).toHaveBeenCalledOnce()
   })
 
   it('routes human content through the exact live parent with rpc attribution', async () => {

+ 21 - 3
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -81,7 +81,12 @@ function scriptedApi(overrides: {
     },
     llm: {
       providers: r => ok(r, { providers: [] }),
-      models: r => ok(r, { groups: [], failures: [] }),
+      models: r => ok(r, {
+        default: { provider: 'test', model: 'test' },
+        routableProviders: [],
+        groups: [],
+        failures: [],
+      }),
       discoverModels: err,
       ...overrides.llm,
     },
@@ -439,7 +444,12 @@ describe('config unary surface', () => {
       },
       llm: {
         providers: record('llm.providers', r => ok(r, { providers: [providerRow] })),
-        models: record('llm.models', r => ok(r, { groups: [group], failures: [] })),
+        models: record('llm.models', r => ok(r, {
+          default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+          routableProviders: ['deepseek-official'],
+          groups: [group],
+          failures: [],
+        })),
         discoverModels: record('llm.discoverModels', r => ok(r, { models: [{ id: 'acme-large', contextWindow: 65536 }] })),
       },
     })
@@ -465,7 +475,15 @@ describe('config unary surface', () => {
     const providers = await c.llm.providers({})
     expect(providers.result).toEqual({ ok: true, value: { providers: [providerRow] } })
     const models = await c.llm.models({})
-    expect(models.result).toEqual({ ok: true, value: { groups: [group], failures: [] } })
+    expect(models.result).toEqual({
+      ok: true,
+      value: {
+        default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+        routableProviders: ['deepseek-official'],
+        groups: [group],
+        failures: [],
+      },
+    })
     const discovered = await c.llm.discoverModels({
       settingsNs: 'llm-pi-ai',
       baseURL: 'https://gateway.acme.example/v1',

+ 12 - 1
packages/host/apiproxy/tests/fetch-carrier.spec.ts

@@ -140,7 +140,18 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
         return { rpcId: request.rpcId, result: { ok: true, value: { providers: [] } } }
       },
       async models(request) {
-        return { rpcId: request.rpcId, result: { ok: true, value: { groups: [], failures: [] } } }
+        return {
+          rpcId: request.rpcId,
+          result: {
+            ok: true,
+            value: {
+              default: { provider: 'test', model: 'test' },
+              routableProviders: [],
+              groups: [],
+              failures: [],
+            },
+          },
+        }
       },
       async discoverModels(request) {
         return { rpcId: request.rpcId, result: { ok: true, value: { models: [] } } }

+ 5 - 0
packages/subagent/subagent/package.json

@@ -52,6 +52,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-user-approval": "workspace:^",
@@ -76,6 +77,9 @@
     "@deepseek-ai/dsh-session-projection-cache": {
       "optional": true
     },
+    "@deepseek-ai/dsh-session-query": {
+      "optional": true
+    },
     "@deepseek-ai/dsh-jobs": {
       "optional": true
     },
@@ -96,6 +100,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
     "@deepseek-ai/dsh-session-projection-cache": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-storage": "workspace:^",
     "@deepseek-ai/dsh-storage-domain": "workspace:^",
     "@deepseek-ai/dsh-jobs": "workspace:^",

+ 26 - 8
packages/subagent/subagent/src/continuation.ts

@@ -35,6 +35,7 @@ import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-ll
 import { SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent } from '@deepseek-ai/dsh-session'
 import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
 import type { ToolRestriction } from '@deepseek-ai/dsh-tools'
 import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts'
 import type { SubagentDescriptorData } from './descriptor.ts'
@@ -939,7 +940,7 @@ export class SubagentContinuationManager {
   }
 
   /**
-   * Cold-resume a persisted child: inspect and authorize its Session, fold the
+   * Cold-resume a persisted child: retain and authorize its prepared Session, fold the
    * generic descriptor, create the Activation through `ctx.agents.resume()`,
    * and submit the waiting turn. This never dispatches through a subagent
    * provider — the persisted Session already holds the initial prefix and the
@@ -951,23 +952,27 @@ export class SubagentContinuationManager {
     content: ContentBlock[],
     options: SubagentFollowupOptions,
   ): Promise<MessageId> {
-    const persistence = this.requirePersistence()
-    let loaded: Awaited<ReturnType<typeof persistence.inspect>>
+    const query = this.requireSessionQuery()
+    let observation: SessionObservation
     try {
-      loaded = await persistence.inspect(childId, options.signal)
+      observation = await query.observeSession(childId, {
+        signal: options.signal,
+      })
     } catch (error: unknown) {
       options.signal.throwIfAborted()
       throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
     }
-    options.signal.throwIfAborted()
+    using source = observation
     this.assertAdmitting(parent)
     // Authorize the persisted header before folding: only the durable child's
     // exact live direct parent may continue it.
-    this.authorizeLineage(parent, childId, loaded.meta.parentSession)
+    this.authorizeLineage(parent, childId, source.header.parentSession)
     // Fold only the child's own suffix: a fork seed replays the parent's log,
     // which may carry an ANCESTOR's descriptor when the parent is itself a
     // continuable child.
-    const descriptor = foldSubagentDescriptor(loaded.events.slice(loaded.meta.seedLength ?? 0))
+    const descriptor = foldSubagentDescriptor(
+      source.events.slice(source.header.seedLength ?? 0),
+    )
     if (descriptor === undefined || descriptor.mode !== 'continuable') {
       throw new SubagentError(
         `subagent "${childId}" has no supported continuation state and cannot be resumed; `
@@ -996,7 +1001,7 @@ export class SubagentContinuationManager {
       if (error instanceof SubagentError) throw error
       throw new SubagentError(`subagent "${childId}" is unavailable`, 'NOT_RESUMABLE', { cause: error })
     }
-    return this.submitMaterialized(activation, content, options.source, parent, options.signal)
+    return await this.submitMaterialized(activation, content, options.source, parent, options.signal)
   }
 
   /**
@@ -1545,6 +1550,19 @@ export class SubagentContinuationManager {
     }
     return persistence
   }
+
+  /** Resolve the Session query service used for cold child observations. */
+  private requireSessionQuery(): SessionQueryEngine {
+    const query = this.ctx.get('sessionQuery')
+    if (query === undefined) {
+      throw new SubagentError(
+        'continuable subagents require session query (load @deepseek-ai/dsh-session-query)',
+        'CONTINUATION_UNAVAILABLE',
+      )
+    }
+    return query
+  }
+
 }
 
 export type { SubagentDescriptorData }

+ 7 - 18
packages/subagent/subagent/src/index.ts

@@ -325,27 +325,16 @@ export class SubagentRuntime extends Service {
 
   /**
    * Enumerate the parent's direct session-backed subagents without loading or
-   * resuming an Agent and without any query service: the listing merges the live
-   * session store with optional session persistence (live-preferred) and
-   * serves each child's durable mode/label from the registered `subagent`
-   * projection unit down a three-rung ladder — the registry's watermark
-   * snapshot for a live child; for a cold one, a durable projection-cache
-   * row when the optional cache serves an own-suffix identity (its `seq`
-   * gate proves the value postdates the fork seed, where a child's own
-   * descriptor is immutable once appended), else one persistence inspection
-   * folded through the registry. The
-   * projection fold is the single classification authority; per-child
-   * diagnostics relay a fold that served no identity or a failed inspection,
-   * never a list-time descriptor parse. Absent persistence, enumeration is
-   * live-only (a cold child cannot be resumed then either, so its absence is
-   * capability absence, not an error). This service consults no Agent
-   * registrations, Activations, or providers.
+   * resuming an Agent. The Session query service supplies one live-preferred
+   * corpus and shared point observations; the projection cache supplies
+   * immutable descriptor hits without opening cold logs. The registered
+   * `subagent` projection remains the sole mode/label classifier.
    *
-   * Every persistence read receives `signal`, and the listing rechecks
-   * cancellation around each of those awaits. Read rejections that settle
+   * Every query receives `signal`, and the listing rechecks cancellation
+   * around each await. Read rejections that settle
    * after an abort become a stable `SubagentError` with code `CANCELLED`.
    * @param parentSessionId - parent session whose direct children are listed.
-   * @param signal - caller-owned cancellation forwarded to persistence reads
+   * @param signal - caller-owned cancellation forwarded to Session queries
    *   and observed around every read await.
    * @returns children and per-child diagnostics ordered by `createdAt`, then id.
    * @throws {@link SubagentError} when the projection registry or the session

+ 76 - 69
packages/subagent/subagent/src/list-children.ts

@@ -1,13 +1,13 @@
 /**
  * Read-only enumeration of durable subagent children and descendant trees
- * straight from the live session store and optional session persistence — no
- * query service. Candidates come from one live-preferred corpus; each child's
- * mode/label is the registered `subagent` projection unit's value, resolved
+ * through the Session query service. Candidates come from one live-preferred
+ * corpus; each child's mode/label is the registered `subagent` projection
+ * unit's value, resolved
  * down a three-rung ladder: the registry's watermark cache for a live child,
  * a durable projection-cache row when it serves an own-suffix identity (the
- * seq gate), and one persistence inspection folded through the registry
- * otherwise, validated against the enumerated lifecycle. The projection fold
- * is the single classification authority — this module parses no descriptor
+ * seq gate), and one shared Session observation otherwise, validated against
+ * the enumerated lifecycle. The projection fold is the single classification
+ * authority — this module parses no descriptor
  * itself. Absent persistence, enumeration is live-only: a cold child is
  * unreachable for resume anyway, so its absence is capability absence, not an
  * error. The module owns no catalog state and does not consult Activation,
@@ -17,17 +17,17 @@
  */
 
 import type { Context } from '@deepseek-ai/cordis'
-import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
+import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
 import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
+import type { SessionObservation, SessionQueryEngine } from '@deepseek-ai/dsh-session-query'
 import { SubagentError } from './error.ts'
 import type { SubagentIdentityProjection } from './projection-types.ts'
 
 /**
- * Concurrent cold inspections per listing; a constant because it bounds one
- * read-only scan of local media, not deployment behavior. Should a networked
- * persistence backend appear, promote it to a validated `Config` field.
+ * Concurrent cold observations per explicit catalog listing. Current Session
+ * persistence providers are local; a networked provider must promote this to
+ * a validated deployment setting.
  */
 const COLD_READ_CONCURRENCY = 4
 
@@ -79,8 +79,8 @@ export type SubagentListEntry =
      * unrecognized-version descriptor — deliberately undistinguished), and
      * for any candidate whose log makes a registered unit's fold or schema
      * throw (deterministic data damage, contained per child); `unavailable`
-     * when the candidate's persistence inspection failed (retried on the
-     * next listing). `unsupported` is never produced; it remains in the
+     * when the candidate's Session observation was absent or transiently
+     * unreadable (retried on the next listing). `unsupported` is never produced; it remains in the
      * union for consumers that route on it.
      */
     readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
@@ -102,7 +102,7 @@ type CorpusRecord = { readonly header: SessionHeader; readonly live: Session | u
 
 interface ListingRuntime {
   readonly projections: SessionProjectionRegistry
-  readonly persistence: SessionPersistence | undefined
+  readonly query: SessionQueryEngine
   readonly cache: SessionProjectionCache | undefined
   readonly corpus: ReadonlyMap<SessionId, CorpusRecord>
   readonly subagentParents: ReadonlySet<SessionId>
@@ -120,8 +120,7 @@ interface PositionedCandidate {
  * serving each identity from the `subagent` projection unit: the registry's
  * watermark snapshot for a live child; for a cold one, a durable
  * projection-cache row when it serves an own-suffix identity (the seq gate),
- * else one bounded-concurrency persistence inspection folded through the
- * registry.
+ * else one bounded-concurrency shared Session observation.
  * @see SubagentRuntime.listChildren for the public cancellation and failure contract.
  * @param ctx - context carrying the session store, the projection registry,
  *   optional persistence, and the optional projection cache.
@@ -206,29 +205,34 @@ async function prepareListing(
     )
   }
   assertListingNotCancelled(signal)
-  const persistence = ctx.get('sessionPersistence')
+  const query = ctx.get('sessionQuery')
+  if (query === undefined) {
+    throw new SubagentError(
+      'listing subagents requires the sessionQuery service (load @deepseek-ai/dsh-session-query)',
+      'SUBAGENT_CONTROL_QUERY_UNAVAILABLE',
+    )
+  }
   // Optional acceleration only: an absent cache service just means every
   // cold candidate takes the authoritative preparation rung, so it carries
   // no error code and no configuration check.
   const cache = ctx.get('sessionProjectionCache')
-  let persistedHeaders: readonly SessionHeader[] = []
-  if (persistence !== undefined) {
-    try {
-      persistedHeaders = await persistence.list(signal)
-    } catch (error: unknown) {
-      // The backend may reject with its own abort failure after observing the
-      // forwarded signal; cancellation stays a stable subagent failure.
-      assertListingNotCancelled(signal)
-      throw error
-    }
+  let records: Awaited<ReturnType<SessionQueryEngine['listSessions']>>
+  try {
+    records = await query.listSessions(signal)
+  } catch (error: unknown) {
     assertListingNotCancelled(signal)
+    throw error
   }
+  assertListingNotCancelled(signal)
   // Live-preferred merge without header reconciliation: a live record wins
   // its id wholesale, exactly as a live-preferred corpus would serve it.
   const corpus = new Map<SessionId, CorpusRecord>()
-  for (const header of persistedHeaders) corpus.set(header.id, { header, live: undefined })
-  for (const session of sessions.list()) {
-    corpus.set(session.header.id, { header: session.header, live: session })
+  for (const record of records) {
+    const live = sessions.get(record.header.id)
+    corpus.set(record.header.id, {
+      header: live?.header ?? record.header,
+      live,
+    })
   }
   const subagentParents = new Set<SessionId>()
   for (const record of corpus.values()) {
@@ -236,7 +240,7 @@ async function prepareListing(
       subagentParents.add(record.header.parentSession)
     }
   }
-  return { projections, persistence, cache, corpus, subagentParents }
+  return { projections, query, cache, corpus, subagentParents }
 }
 
 /** Resolve projection-backed rows for aligned candidates with bounded cold reads. */
@@ -245,7 +249,7 @@ async function resolveCandidateRows(
   listing: ListingRuntime,
   signal: AbortSignal | undefined,
 ): Promise<(SubagentListEntry | undefined)[]> {
-  const { projections, persistence, cache, subagentParents } = listing
+  const { projections, query, cache, subagentParents } = listing
   const rows: (SubagentListEntry | undefined)[] = Array.from({ length: candidates.length })
   const coldReads: { index: number; header: SessionHeader }[] = []
   candidates.forEach((candidate, index) => {
@@ -254,36 +258,33 @@ async function resolveCandidateRows(
       coldReads.push({ index, header: candidate.header })
       return
     }
-    // The registry's watermark cache serves the live value with zero log
-    // reads; a live child without an identity yet is the creation window
-    // before the establishing provider appends its descriptor.
+    // Read only the identity unit. A live child without an identity yet is the
+    // creation window before the establishing provider appends its descriptor.
     let identity: SubagentIdentityProjection | null | undefined
     try {
-      identity = projections.snapshot(candidate.live).values.subagent
+      identity = projections.snapshot(candidate.live, ['subagent']).values.subagent
     } catch {
-      // The snapshot folds EVERY registered unit over this child's log, so
-      // any unit's fold or schema can reject damaged payloads. That is
-      // deterministic data damage in this one child; it degrades to one
-      // corrupt diagnostic instead of failing the whole listing.
+      // A rejecting identity fold is deterministic data damage in this child;
+      // contain it as one diagnostic instead of failing the whole listing.
       rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
       return
     }
     // The unit's serializable no-value sentinel is `null`; `undefined` can
     // only mean the key was dropped at a JSON boundary. Both are no value.
-    if (identity === undefined || identity === null) return
+    if (identity === undefined || identity === null
+      || identity.seq < (candidate.header.seedLength ?? 0)) return
     rows[index] = childRow(childId, identity, 'running', subagentParents.has(childId))
   })
 
-  // Cold candidates exist only when persistence listed them, so the narrow
-  // re-check is about types, not reachability.
-  if (persistence !== undefined && coldReads.length > 0) {
+  // Cold candidates came from the query corpus and are resolved concurrently.
+  if (coldReads.length > 0) {
     const queue = [...coldReads]
     await Promise.all(Array.from(
       { length: Math.min(COLD_READ_CONCURRENCY, queue.length) },
       async () => {
         for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
           rows[job.index] = await resolveColdIdentity(
-            persistence, projections, cache, job.header,
+            query, cache, job.header,
             subagentParents.has(job.header.id), signal,
           )
         }
@@ -338,16 +339,14 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number {
 /**
  * Resolve one cold candidate down the remaining ladder: a durable
  * projection-cache row when it serves an own-suffix identity (the seq gate),
- * otherwise one persistence inspection folded through the projection
- * registry (the same detached recipe the API proxy uses for detached session
- * projections). A failed inspection is one transient `unavailable` row
- * retried on the next listing; an inspection naming another lifecycle, and a
+ * otherwise one shared Session observation. An absent or transiently failed
+ * observation is one `unavailable` row retried on the next listing; an observation
+ * source naming another lifecycle, and a
  * settled log the fold cannot identify — or that makes any registered unit
  * throw — are final, so they report `corrupt`.
  */
 async function resolveColdIdentity(
-  persistence: SessionPersistence,
-  projections: SessionProjectionRegistry,
+  query: SessionQueryEngine,
   cache: SessionProjectionCache | undefined,
   header: SessionHeader,
   hasChildren: boolean,
@@ -357,7 +356,7 @@ async function resolveColdIdentity(
   if (cache !== undefined) {
     let cached: SubagentIdentityProjection | null | undefined
     try {
-      cached = cache.cachedSnapshot(header)?.values.subagent
+      cached = cache.cachedSnapshot(header, ['subagent'])?.values.subagent
     } catch {
       // Unlike the preparation fold below, a throwing cache read renders no
       // verdict: the cache is derived data, so its damage (a poisoned stored
@@ -377,32 +376,35 @@ async function resolveColdIdentity(
     }
   }
   assertListingNotCancelled(signal)
-  let inspected: { meta: SessionHeader; events: readonly SessionEvent[] }
+  let observation: SessionObservation
   try {
-    inspected = await persistence.inspect(childId, signal)
-  } catch {
-    // Per-child isolation: the child vanished or its backend read failed —
-    // one diagnostic row, and the listing itself still succeeds.
+    observation = await query.observeSession(childId, {
+      ...(signal === undefined ? {} : { signal }),
+    })
+  } catch (error: unknown) {
+    // Per-child isolation: durable corruption is stable; absence and backend
+    // failures remain retryable. Either way, the listing itself still succeeds.
     assertListingNotCancelled(signal)
-    return { kind: 'diagnostic', id: childId, reason: 'unavailable' }
+    return {
+      kind: 'diagnostic',
+      id: childId,
+      reason: sessionQueryCode(error) === 'SESSION_QUERY_CORRUPT_SESSION'
+        || sessionQueryCode(error) === 'SESSION_QUERY_SOURCE_CONFLICT'
+        ? 'corrupt'
+        : 'unavailable',
+    }
   }
+  using ownedObservation = observation
   assertListingNotCancelled(signal)
   // A session id names a slot, not a lifecycle: a child deleted and
   // re-published under another owner between the enumeration and this read
   // must not leak into the old parent's listing.
-  if (!sameLifecycle(inspected.meta, header)) {
-    return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
-  }
-  let identity: SubagentIdentityProjection | null | undefined
-  try {
-    identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
-  } catch {
-    // The restore folds EVERY registered unit over this child's log, so any
-    // unit's fold or schema can reject damaged payloads — deterministic data
-    // damage in this one child, contained as its own corrupt diagnostic.
+  if (!sameLifecycle(ownedObservation.header, header)) {
     return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
   }
-  if (identity === undefined || identity === null) {
+  const identity = ownedObservation.projections?.values.subagent
+  if (identity === undefined || identity === null
+    || identity.seq < (header.seedLength ?? 0)) {
     return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
   }
   return childRow(childId, identity, 'inactive', hasChildren)
@@ -437,6 +439,7 @@ function childRow(
 /** Immutable header fields that distinguish one session lifecycle from another under the same id. */
 const LIFECYCLE_WITNESS_KEYS = [
   'version', 'id', 'createdAt', 'cwd', 'parentSession', 'seedLength', 'delegationDepth',
+  'origin', 'agentPreset',
 ] as const
 
 /** Whether an inspected log still belongs to the enumerated lifecycle. */
@@ -450,3 +453,7 @@ function assertListingNotCancelled(signal: AbortSignal | undefined): void {
     throw new SubagentError('subagent listing was cancelled', 'CANCELLED')
   }
 }
+
+function sessionQueryCode(error: unknown): unknown {
+  return error instanceof Error && 'code' in error ? error.code : undefined
+}

+ 2 - 0
packages/subagent/subagent/tests/continuation-inheritance.spec.ts

@@ -24,6 +24,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
 import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import SubagentRuntime from '../src/index.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
@@ -45,6 +46,7 @@ async function setup(script: Script) {
   await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
   await ctx.plugin(ApprovalService)
   await ctx.plugin(AgentLoop, { agents: [] })
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(SubagentRuntime)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(SubagentFork, { providerName: 'fork' })

+ 18 - 2
packages/subagent/subagent/tests/continuation.spec.ts

@@ -22,6 +22,7 @@ import SubagentRuntime, {
 } from '../src/index.ts'
 import type { SubagentRunEndInfo, SubagentRunInfo } from '../src/index.ts'
 import * as SubagentInvariant from '../src/invariant.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
@@ -65,7 +66,10 @@ afterEach(async () => {
 })
 
 /** Boot the full continuable stack: loop, persistence, providers, and subagents. */
-async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
+async function setupWith(
+  adapter: LlmAdapter,
+  options: { persistence?: boolean; sessionQuery?: boolean } = {},
+) {
   const ctx = new Context()
   await mountAgentLoopTestDependencies(ctx)
   let disposePersistence: (() => Promise<void>) | undefined
@@ -81,6 +85,7 @@ async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean }
     })
   }
   await ctx.plugin(AgentLoop, { agents: [] })
+  if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery)
   await ctx.plugin(SubagentRuntime)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(SubagentFork, { providerName: 'fork' })
@@ -455,6 +460,7 @@ describe('SubagentRuntime.startContinuable', () => {
     // afterEach closes it before removing the root (even on a failure path).
     cleanups.push(async () => { await freshPersistence.dispose() })
     await fresh.plugin(AgentLoop, { agents: [] })
+    await fresh.plugin(TestSessionQuery)
     await fresh.plugin(SubagentRuntime)
     await fresh.plugin(SubagentSpawn, { providerName: 'spawn' })
     const freshParent = fresh.agentLoop.create(SessionId('routeless-resume'), {})
@@ -518,6 +524,16 @@ describe('SubagentRuntime.startContinuable', () => {
 })
 
 describe('SubagentRuntime.followup residency routing', () => {
+  it('fails a cold follow-up when Session query is unavailable', async () => {
+    const { ctx, parent } = await setupWith(new MockAdapter([]), {
+      persistence: false,
+      sessionQuery: false,
+    })
+
+    await expect(followup(ctx, parent, SessionId('cold-without-query'), message('continue')))
+      .rejects.toMatchObject({ code: 'CONTINUATION_UNAVAILABLE' })
+  })
+
   it('enqueues in the same Activation while it is running, preserving one inbox FIFO', async () => {
     const releaseFirst = Promise.withResolvers<undefined>()
     const adapter = new GatedAdapter([
@@ -669,7 +685,7 @@ describe('SubagentRuntime.followup residency routing', () => {
     const started = await ctx.subagents.startContinuable(startSpec(parent))
     await waitNoActivation(ctx, started.childId)
     const inspectStarted = Promise.withResolvers<undefined>()
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect').mockImplementation((_id, signal) => {
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession').mockImplementation((_id, signal) => {
       return new Promise<never>((_resolve, reject) => {
         if (signal === undefined) {
           reject(new Error('cold inspection must receive the followup signal'))

+ 127 - 38
packages/subagent/subagent/tests/list-children.spec.ts

@@ -9,6 +9,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
 import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
 import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
 import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
 import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
 import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
 import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
@@ -23,6 +24,7 @@ import SubagentRuntime, {
 import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
 import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 type Script = ConstructorParameters<typeof MockAdapter>[0]
 
@@ -51,6 +53,7 @@ async function setup(
     ctx.provide('storageDomain', facility)
     await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
   }
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(SubagentRuntime)
   await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
   await ctx.plugin(SubagentFork, { providerName: 'fork' })
@@ -152,10 +155,11 @@ const hostileProjectionDefinition = {
 } satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }>
 
 describe('SubagentRuntime.listChildren', () => {
-  it('lists live children without persistence, query services, or the continuation runtime', async () => {
+  it('lists live children without persistence or the continuation runtime', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
     await ctx.plugin(SessionProjectionRegistry)
+    await ctx.plugin(TestSessionQuery)
     await ctx.plugin(SubagentRuntime)
     expect(ctx.get('jobs')).toBeUndefined()
     expect(ctx.get('agents')).toBeUndefined()
@@ -196,6 +200,17 @@ describe('SubagentRuntime.listChildren', () => {
     )
   })
 
+  it('fails loud when the Session query service is not mounted', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    await ctx.plugin(SubagentRuntime)
+
+    await expect(ctx.subagents.listChildren(SessionId('no-query-parent'))).rejects.toThrow(
+      expect.objectContaining({ code: 'SUBAGENT_CONTROL_QUERY_UNAVAILABLE' }) as Error,
+    )
+  })
+
   it('lists a persisted continuable child as inactive with its durable label', async () => {
     const { ctx, parent } = await setup([textResponse('done')])
     const childId = await startChild(ctx, parent, 'summarize the doc')
@@ -301,6 +316,76 @@ describe('SubagentRuntime.listChildren', () => {
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([])
   })
 
+  it('contains a live child projection failure as one corrupt diagnostic', async () => {
+    const { ctx, parent } = await setup([])
+    const childId = SessionId('live-projection-failure')
+    const child = ctx.sessions.create(childId, {
+      meta: { parentSession: parent.id, origin: 'subagent' },
+    })
+    child.append('subagent/descriptor', descriptorPayload('broken live child'))
+    const snapshot = ctx.sessionProjections.snapshot.bind(ctx.sessionProjections)
+    vi.spyOn(ctx.sessionProjections, 'snapshot').mockImplementation((session, keys) => {
+      if (session.id === childId) throw new Error('projection failed')
+      return snapshot(session, keys)
+    })
+
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
+      kind: 'diagnostic', id: childId, reason: 'corrupt',
+    })
+  })
+
+  it('maps a non-Error cold observation failure to unavailable', async () => {
+    const { ctx, parent } = await setup([])
+    const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000aa01', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('unreadable child')))
+    const observe = ctx.sessionQuery.observeSession.bind(ctx.sessionQuery)
+    vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id, options) => {
+      if (id === childId) {
+        return Promise.reject('backend unavailable') // oxlint-disable-line typescript/prefer-promise-reject-errors
+      }
+      return observe(id, options)
+    })
+
+    await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
+      kind: 'diagnostic', id: childId, reason: 'unavailable',
+    })
+  })
+
+  it('releases a cold observation when cancellation lands after its read', async () => {
+    const { ctx, parent } = await setup([])
+    const controller = new AbortController()
+    const childId = await authorChild(ctx, '00000000-0000-4000-8000-00000000aa02', {
+      parentSession: parent.id,
+      origin: 'subagent',
+    }, childEvents(descriptorPayload('cancelled child')))
+    const dispose = vi.fn()
+    vi.spyOn(ctx.sessionQuery, 'observeSession').mockImplementation((id) => {
+      if (id !== childId) throw new Error(`unexpected observation: ${id}`)
+      controller.abort(new Error('cancelled after observation'))
+      return Promise.resolve({
+        source: 'prepared',
+        header: {
+          version: SESSION_FORMAT_VERSION,
+          id: childId,
+          createdAt: 1,
+          parentSession: parent.id,
+          origin: 'subagent',
+        },
+        events: [],
+        cursor: -1,
+        projections: { asOfSeq: -1, values: {} },
+        retain: vi.fn(),
+        [Symbol.dispose]: dispose,
+      } as unknown as SessionObservation)
+    })
+
+    await expect(ctx.subagents.listChildren(parent.id, controller.signal))
+      .rejects.toMatchObject({ code: 'CANCELLED' })
+    expect(dispose).toHaveBeenCalledOnce()
+  })
+
   it('lists a one-shot child with its durable creation label', async () => {
     const { ctx, parent } = await setup([])
     const labeled = await authorChild(ctx, '00000000-0000-4000-8000-00000000ab02', {
@@ -428,7 +513,7 @@ describe('SubagentRuntime.listChildren', () => {
       asOfSeq: 2,
       values: { subagent: { mode: 'continuable', label: 'cached own', seq: 2 } },
     })
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: child, label: 'cached own', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -458,7 +543,7 @@ describe('SubagentRuntime.listChildren', () => {
       asOfSeq: 2,
       values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
     })
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -481,12 +566,12 @@ describe('SubagentRuntime.listChildren', () => {
       parentSession: parent.id,
       origin: 'subagent',
     }, childEvents(descriptorPayload('reborn child')))
-    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+    const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = async (sessionId, signal) => {
       const result = await original(sessionId, signal)
       if (sessionId !== reborn) return result
       // The id was re-published as a different lifecycle after enumeration.
-      return { ...result, meta: mutate(result.meta) }
+      return { ...result, inspection: { ...result.inspection, meta: mutate(result.inspection.meta) } }
     }
     const entries = await ctx.subagents.listChildren(parent.id)
     expect(entries).toContainEqual({ kind: 'diagnostic', id: reborn, reason: 'corrupt' })
@@ -504,7 +589,7 @@ describe('SubagentRuntime.listChildren', () => {
     }, childEvents(descriptorPayload('actually valid')))
     // A stale cached sentinel must not out-rank the authoritative re-fold.
     ctx.sessionProjectionCache.cachedSnapshot = () => ({ asOfSeq: 0, values: { subagent: null } })
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: healthy, label: 'actually valid', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -512,7 +597,7 @@ describe('SubagentRuntime.listChildren', () => {
     expect(inspect).toHaveBeenCalledTimes(1)
   })
 
-  it('maps a child rejected by persistence inspection to unavailable', async () => {
+  it('maps a child rejected by persistence validation to corrupt', async () => {
     const { ctx, parent } = await setup([])
     // The surface-eligible user/message lacks its required surfaceOp, so the
     // first-party inspection rejects before any projection fold can run.
@@ -530,7 +615,7 @@ describe('SubagentRuntime.listChildren', () => {
       { type: 'subagent/descriptor', seq: 2, time: 3, data: descriptorPayload('broken surface') },
     ] as SessionEvent[])
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'unavailable' }])
+    expect(entries).toEqual([{ kind: 'diagnostic', id: invalid, reason: 'corrupt' }])
   })
 
   it('diagnoses a malformed descriptor payload as corrupt', async () => {
@@ -556,10 +641,10 @@ describe('SubagentRuntime.listChildren', () => {
     expect(entries).toEqual([{ kind: 'diagnostic', id: future, reason: 'corrupt' }])
   })
 
-  it('lists a fork whose seed replays an ancestor descriptor under that identity', async () => {
+  it('rejects a fork whose only descriptor belongs to its inherited seed', async () => {
     const { ctx, parent } = await setup([])
-    // The last-wins fold serves a seed-replayed ancestor descriptor until the
-    // child's own descriptor overrides it (known deviation #1 in the design).
+    // A seed-replayed descriptor predates this child's own suffix and cannot
+    // identify the fork as a resumable child.
     const seed = childEvents(descriptorPayload('ancestor label'))
     const forkChild = await authorChild(ctx, '00000000-0000-4000-8000-0000000000f0', {
       parentSession: parent.id,
@@ -567,12 +652,7 @@ describe('SubagentRuntime.listChildren', () => {
       origin: 'subagent',
     }, seed)
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toEqual([
-      {
-        kind: 'child', id: forkChild, label: 'ancestor label', mode: 'continuable',
-        activity: 'inactive', hasChildren: false,
-      },
-    ])
+    expect(entries).toEqual([{ kind: 'diagnostic', id: forkChild, reason: 'corrupt' }])
   })
 
   it('does not filter by provider availability: children of unmounted providers stay listed', async () => {
@@ -613,7 +693,7 @@ describe('SubagentRuntime.listChildren', () => {
     })
   })
 
-  it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => {
+  it('does not evaluate an unrelated wire view when exposing a live child identity', async () => {
     const { ctx, parent } = await setup([])
     ctx.sessionProjections.register(hostileProjectionDefinition)
     const poisonedId = SessionId('live-poisoned-child')
@@ -629,7 +709,10 @@ describe('SubagentRuntime.listChildren', () => {
     healthy.append('turn/start', { turn: 1 })
     healthy.append('subagent/descriptor', descriptorPayload('live healthy'))
     const entries = await ctx.subagents.listChildren(parent.id)
-    expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' })
+    expect(entries).toContainEqual({
+      kind: 'child', id: poisonedId, label: 'poison me', mode: 'continuable',
+      activity: 'running', hasChildren: false,
+    })
     expect(entries).toContainEqual({
       kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable',
       activity: 'running', hasChildren: false,
@@ -652,8 +735,8 @@ describe('SubagentRuntime.listChildren', () => {
       parentSession: parent.id,
       origin: 'subagent',
     }, childEvents(descriptorPayload('flaky storage')))
-    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = (sessionId, signal) => {
+    const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = (sessionId, signal) => {
       if (sessionId === flaky) {
         return Promise.reject(new Error('backend read failed'))
       }
@@ -669,7 +752,7 @@ describe('SubagentRuntime.listChildren', () => {
     })
     // Nothing is memoized: with the backend healthy again, the next listing
     // folds the same child to its identity.
-    ctx.sessionPersistence.inspect = original
+    ctx.sessionPersistence.borrowSession = original
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toContainEqual({
       kind: 'child', id: flaky, label: 'flaky storage', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -723,8 +806,8 @@ describe('SubagentRuntime.listChildren', () => {
       origin: 'subagent',
     }, childEvents(descriptorPayload('grandchild')))
     const inspected: SessionId[] = []
-    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = (sessionId, signal) => {
+    const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = (sessionId, signal) => {
       inspected.push(sessionId)
       return original(sessionId, signal)
     }
@@ -755,8 +838,8 @@ describe('SubagentRuntime.listChildren', () => {
     live.append('subagent/descriptor', descriptorPayload('live mixed child'))
 
     const inspected: SessionId[] = []
-    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = (sessionId, signal) => {
+    const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = (sessionId, signal) => {
       inspected.push(sessionId)
       return original(sessionId, signal)
     }
@@ -778,7 +861,7 @@ describe('SubagentRuntime.listChildren', () => {
     await vi.waitFor(() => {
       expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
     }, { timeout: 5_000 })
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: childId, label: 'cached child', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -797,7 +880,7 @@ describe('SubagentRuntime.listChildren', () => {
       activity: 'inactive', hasChildren: false,
     }]
     // No stored row at all for a foreign child this process never ran.
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual(expected)
     expect(inspect).toHaveBeenCalledTimes(1)
     // A stored row whose cut predates the descriptor: the subagent key is
@@ -814,7 +897,7 @@ describe('SubagentRuntime.listChildren', () => {
       parentSession: parent.id,
       origin: 'subagent',
     }, childEvents(descriptorPayload('uncacheable child')))
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: foreign, label: 'uncacheable child', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -833,7 +916,7 @@ describe('SubagentRuntime.listChildren', () => {
       // is derived data, so its failure must not become a verdict.
       throw new Error('poisoned cache row')
     }
-    const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
+    const inspect = vi.spyOn(ctx.sessionPersistence, 'borrowSession')
     await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
       kind: 'child', id: recovered, label: 'recovered child', mode: 'continuable',
       activity: 'inactive', hasChildren: false,
@@ -912,7 +995,7 @@ describe('SubagentRuntime.listChildren', () => {
     }, childEvents(descriptorPayload('cancelled cold read')))
     const controller = new AbortController()
     const entered = Promise.withResolvers<undefined>()
-    ctx.sessionPersistence.inspect = (_sessionId, signal) => {
+    ctx.sessionPersistence.borrowSession = (_sessionId, signal) => {
       entered.resolve(undefined)
       return new Promise((_resolve, reject) => {
         signal?.addEventListener('abort', () => {
@@ -935,8 +1018,8 @@ describe('SubagentRuntime.listChildren', () => {
       origin: 'subagent',
     }, childEvents(descriptorPayload('cancelled mid-listing')))
     const controller = new AbortController()
-    const original = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+    const original = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = async (sessionId, signal) => {
       const result = await original(sessionId, signal)
       controller.abort()
       return result
@@ -954,7 +1037,7 @@ describe('SubagentRuntime.listChildren', () => {
       origin: 'subagent',
     }, childEvents(descriptorPayload('aborted behind a failure')))
     const controller = new AbortController()
-    ctx.sessionPersistence.inspect = () => {
+    ctx.sessionPersistence.borrowSession = () => {
       // The read fails while the caller aborts: cancellation normalization
       // must fail the listing rather than return a one-diagnostic success.
       controller.abort()
@@ -1188,11 +1271,17 @@ describe('SubagentRuntime.listDescendants', () => {
       createdAt: 1,
       origin: 'subagent',
     }, childEvents(descriptorPayload('lineage checked')))
-    const realInspect = ctx.sessionPersistence.inspect.bind(ctx.sessionPersistence)
-    ctx.sessionPersistence.inspect = async (sessionId, signal) => {
+    const realInspect = ctx.sessionPersistence.borrowSession.bind(ctx.sessionPersistence)
+    ctx.sessionPersistence.borrowSession = async (sessionId, signal) => {
       const inspected = await realInspect(sessionId, signal)
       // The exact read reports a different durable parent than enumeration did.
-      return { ...inspected, meta: { ...inspected.meta, parentSession: SessionId('someone-else') } }
+      return {
+        ...inspected,
+        inspection: {
+          ...inspected.inspection,
+          meta: { ...inspected.inspection.meta, parentSession: SessionId('someone-else') },
+        },
+      }
     }
     await expect(ctx.subagents.listDescendants(parent.id)).resolves.toEqual([
       { kind: 'diagnostic', id: childId, reason: 'corrupt', parentId: parent.id, depth: 1 },

+ 14 - 0
packages/subagent/subagent/tests/test-session-query.ts

@@ -0,0 +1,14 @@
+/** Minimal concrete Session query for tests that exercise only corpus and point reads. */
+
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+
+/** Session query implementation whose search faces are intentionally unavailable. */
+export class TestSessionQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is not configured in this test'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}

+ 3 - 0
packages/subagent/subagent/tsconfig.json

@@ -47,6 +47,9 @@
     {
       "path": "../../session/session-projection-cache"
     },
+    {
+      "path": "../../session-query/session-query"
+    },
     {
       "path": "../../jobs/jobs"
     },

+ 1 - 0
packages/subagent/tool-subagent-control/package.json

@@ -54,6 +54,7 @@
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
     "@deepseek-ai/dsh-session-projection": "workspace:^",
+    "@deepseek-ai/dsh-session-query": "workspace:^",
     "@deepseek-ai/dsh-subagent": "workspace:^",
     "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
     "@deepseek-ai/dsh-tools": "workspace:^",

+ 2 - 0
packages/subagent/tool-subagent-control/tests/list-agents.spec.ts

@@ -17,6 +17,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import * as tool from '../src/list-agents.ts'
 import { parkParent } from './park-parent.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 /** One scripted response that may wait on a caller-released gate before streaming. */
 interface GatedEntry {
@@ -57,6 +58,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
   const root = mkdtempSync(join(tmpdir(), 'dsh-tool-list-agents-'))
   roots.push(root)
   await ctx.plugin(JsonlSessionPersistence, { root })
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SubagentRuntime)

+ 14 - 0
packages/subagent/tool-subagent-control/tests/test-session-query.ts

@@ -0,0 +1,14 @@
+/** Minimal concrete Session query for continuation and catalog integration tests. */
+
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+
+/** Session query implementation whose search faces are outside these tests. */
+export class TestSessionQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('session search is not configured in this test'))
+  }
+
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('event search is not configured in this test'))
+  }
+}

+ 2 - 0
packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts

@@ -16,6 +16,7 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
 import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
 import * as tool from '../src/index.ts'
 import { parkParent } from './park-parent.ts'
+import { TestSessionQuery } from './test-session-query.ts'
 
 /** One scripted response that may wait on a caller-released gate before streaming. */
 interface GatedEntry {
@@ -56,6 +57,7 @@ async function setupWith(adapter: MockAdapter | GatedAdapter) {
   const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-control-'))
   roots.push(root)
   await ctx.plugin(JsonlSessionPersistence, { root })
+  await ctx.plugin(TestSessionQuery)
   await ctx.plugin(AgentLoop, { agents: [] })
   await ctx.plugin(SessionProjectionRegistry)
   await ctx.plugin(SubagentRuntime)