diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 94dd4e518f..5dc133470c 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -15,8 +15,8 @@
en: 1fdf707322fb6996
zh: 0204f7e9d4aa3cce
/session-controller/use-this-package:
- en: d26a58a8f2406cd5
- zh: 82135eceb9adb628
+ en: 5209c0658a6f643b
+ zh: bb2f9575215bac7a
/session-controller/client-references:
en: 41bdda1ac32df558
zh: aa5d0e325924066b
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index ca922877f8..94daea954c 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -27,6 +27,8 @@ Desktop analytics follows the [product collection policy](../../client/product-a
## Use this package
+The controller registers the `agentPreset` projection independently of preset execution support. Its value is the recorded preset id or null; key presence does not indicate that `agentPresets` is composed.
+
History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data.
The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index 585c4e0363..79773c94fb 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -27,6 +27,8 @@ kind: "package-reference"
## 使用本包
+控制器独立于预设执行能力注册 `agentPreset` 投影。其值为已记录的预设 id 或 null;键存在不代表组合中包含 `agentPresets`。
+
历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。
Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。
diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts
index 3f0fa77944..41f56e4f14 100644
--- a/packages/api/session-controller/src/agent.ts
+++ b/packages/api/session-controller/src/agent.ts
@@ -7,14 +7,16 @@ import type {
Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef,
} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-agent-default-model'
-import type {} from '@deepseek-ai/dsh-agent-preset-registry'
+import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence'
import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
import { RemoteError } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-typert-registry'
-import type { ModelSelection } from './types.ts'
+import type { ModelSelection, ModelSelectionProjectionState } from './types.ts'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
+import { modelSelectionProjection } from './model-selection-projection.ts'
/** Cold Session identity absent from persistence. */
export class ApiSessionNotFound extends Error {}
@@ -138,13 +140,24 @@ export async function inspectApiSession(
/** Owns every operation that may create, resume, or configure a Web Agent. */
export class ApiSessionAgentController {
+ private modelSelection!: ProjectionHandle
+ private agentPreset!: ProjectionHandle
private readonly resumes = new Map>()
private readonly creations = new Map>()
private readonly selections = new WeakMap()
private readonly imageAdmissionChains = new WeakMap>()
/** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */
- constructor(private readonly ctx: Context) {
+ constructor(private readonly ctx: Context) {}
+
+ /**
+ * Acquire projection readers before exposing dependent operations.
+ * @returns when all required registrations are ready.
+ */
+ async initialize(): Promise {
+ const ctx = this.ctx
+ this.modelSelection = await ctx.sessionProjections.register(modelSelectionProjection)
+ this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition)
ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => {
const found = await this.resolveAgent(sessionId)
if ('error' in found) throw found.error
@@ -283,10 +296,7 @@ export class ApiSessionAgentController {
selectionFor(agent: Agent): InstalledSelection {
const installed = this.selections.get(agent)
if (installed !== undefined) return installed
- const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection')
- if (projectionState === undefined) {
- throw new Error('api-session: required modelSelection projection is not registered')
- }
+ const projectionState = this.modelSelection.stateOf(agent.session)
let picked = projectionState.pending === null
? undefined
: agentModelSelection(projectionState.pending)
@@ -355,10 +365,10 @@ export class ApiSessionAgentController {
/**
* Read the current Agent preset from the Session projection.
* @param session - live Session whose projection state is available.
- * @returns the current preset, or undefined when the capability is absent.
+ * @returns the current preset, or undefined when the Session has no recorded preset.
*/
presetForSession(session: Session): string | undefined {
- return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined
+ return this.agentPreset.stateOf(session) ?? undefined
}
/**
@@ -506,7 +516,7 @@ export class ApiSessionAgentController {
/**
* Read the current Agent preset from an all-projections observation.
* @param observation - exact Session observation carrying its projection snapshot.
- * @returns the current preset, or undefined when the capability is absent.
+ * @returns the current preset, or undefined when the Session has no recorded preset.
*/
presetForObservation(observation: SessionObservation): string | undefined {
if (observation.projections === undefined) {
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index 0ba5bf8ff0..fbb4234fdf 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -4,7 +4,7 @@ import { hostname } from 'node:os'
import { resolve } from 'node:path'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-fs'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-client-file-upload'
@@ -25,7 +25,6 @@ import { SessionHistoryController } from './history.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList } from './list.ts'
import { buildModelCatalog, hasProviderApiKey } from './catalog.ts'
-import { installModelSelectionProjection } from './model-selection-projection.ts'
import { SessionSkillCatalog } from './skill-catalog.ts'
import { SessionMediaReferences } from './media-references.ts'
import { ArchivedSessionGate } from './archived-session-gate.ts'
@@ -122,16 +121,17 @@ export class SessionController extends TypertRemoteService {
listWorkSliceMs: z.natural().min(1).default(16),
})
- private readonly agents: ApiSessionAgentController
- private readonly commands: SessionCommandController
- private readonly controlState: SessionControlController
- private readonly history: SessionHistoryController
- private readonly listState: ApiSessionList
+ private agents!: ApiSessionAgentController
+ private commands!: SessionCommandController
+ private controlState!: SessionControlController
+ private history!: SessionHistoryController
+ private listState!: ApiSessionList
private readonly openPath: (path: string, signal: AbortSignal) => Promise
private readonly fileApplications: typeof nativeFileApplications
private readonly openFileApplication: typeof openNativeFileApplication
private readonly revealPath: (path: string, signal: AbortSignal) => Promise
private readonly canOpenPath: () => boolean
+ private readonly listWorkSliceMs: number
private readonly promotions = new Set>()
/**
@@ -141,9 +141,19 @@ export class SessionController extends TypertRemoteService {
*/
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
super(ctx, 'sessionController', { namespace: 'session' })
- const resolved = SessionController.Config(config)
- installModelSelectionProjection(ctx)
+ this.listWorkSliceMs = SessionController.Config(config).listWorkSliceMs
+ this.fileApplications = internals.fileApplications ?? nativeFileApplications
+ this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication
+ this.openPath = internals.openPath ?? openNativeAssociatedPath
+ this.revealPath = internals.revealPath ?? revealNativePath
+ this.canOpenPath = internals.canOpenPath
+ ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
this.agents = new ApiSessionAgentController(ctx)
+ await this.agents.initialize()
this.commands = new SessionCommandController(ctx, this.agents, process.cwd())
ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => {
const result = await this.agents.resolveAgent(sessionId)
@@ -157,13 +167,8 @@ export class SessionController extends TypertRemoteService {
await Promise.allSettled([...this.promotions])
}, 'session-controller.promotions')
this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) })
- this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs)
- this.fileApplications = internals.fileApplications ?? nativeFileApplications
- this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication
- this.openPath = internals.openPath ?? openNativeAssociatedPath
- this.revealPath = internals.revealPath ?? revealNativePath
- this.canOpenPath = internals.canOpenPath
- ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
+ this.listState = new ApiSessionList(ctx, this.listWorkSliceMs)
+ await this.listState.initialize()
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionMediaReferences)
ctx.plugin(SessionSkillCatalog)
diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts
index d7ef1ad43c..f109fee5e0 100644
--- a/packages/api/session-controller/src/list.ts
+++ b/packages/api/session-controller/src/list.ts
@@ -80,8 +80,14 @@ export class ApiSessionList {
* @param ctx - Host context carrying Session, query, persistence, and projection services.
* @param workSliceMs - Resolved positive integral list-work budget in milliseconds.
*/
- constructor(private readonly ctx: Context, private readonly workSliceMs: number) {
- ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
+ constructor(private readonly ctx: Context, private readonly workSliceMs: number) {}
+
+ /**
+ * Acquire projection readers before exposing dependent operations.
+ * @returns when all required registrations are ready.
+ */
+ async initialize(): Promise {
+ await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({
key: 'sessionListMetadata',
stateSchema: sessionListMetadataSchema,
init: () => ({ blank: true, lastPromptAt: null }),
@@ -89,18 +95,16 @@ export class ApiSessionList {
wire: { viewSchema: sessionListMetadataSchema, view: state => state },
stateVersion: 1,
})
- ctx.inject(['attachments'], (attachmentCtx) => {
- ctx.sessionProjections.register<'imageLimits', null>({
- key: 'imageLimits',
- stateSchema: z.null(),
- init: () => null,
- apply: state => state,
- wire: {
- viewSchema: imageLimitsSchema,
- view: () => attachmentCtx.attachments.imageLimits,
- },
- stateVersion: 1,
- })
+ await this.ctx.sessionProjections.register<'imageLimits', null>({
+ key: 'imageLimits',
+ stateSchema: z.null(),
+ init: () => null,
+ apply: state => state,
+ wire: {
+ viewSchema: imageLimitsSchema,
+ view: () => this.ctx.attachments.imageLimits,
+ },
+ stateVersion: 1,
})
}
diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts
index 47eddde181..071cf5e685 100644
--- a/packages/api/session-controller/src/model-selection-projection.ts
+++ b/packages/api/session-controller/src/model-selection-projection.ts
@@ -1,6 +1,5 @@
/** Durable model-selection intent and request-use projection. */
-import type { Context } from '@deepseek-ai/cordis'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { z } from 'zod'
@@ -55,7 +54,8 @@ function applyModelSelectionProjection(
: { lastUsed, pending }
}
-const modelSelectionProjection = {
+/** Last-used and pending model selections shared by Session controller readers. */
+export const modelSelectionProjection = {
key: 'modelSelection',
stateSchema: modelSelectionProjectionStateSchema,
init: () => ({ lastUsed: null, pending: null }),
@@ -73,11 +73,3 @@ function sameSelection(left: ModelSelection | null, right: ModelSelection | null
&& left.model === right.model
&& left.reasoningEffort === right.reasoningEffort)
}
-
-/**
- * Register the durable model-selection projection when the registry is present.
- * @param ctx - Session Controller context.
- */
-export function installModelSelectionProjection(ctx: Context): void {
- ctx.sessionProjections.register(modelSelectionProjection)
-}
diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts
index 4756ae45b0..10a062d87e 100644
--- a/packages/api/session-controller/tests/agent.host.spec.ts
+++ b/packages/api/session-controller/tests/agent.host.spec.ts
@@ -18,7 +18,6 @@ import {
ApiSessionSubagentOwnership,
inspectApiSession,
} from '../src/agent.ts'
-import { installModelSelectionProjection } from '../src/model-selection-projection.ts'
import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
const roots: Context[] = []
@@ -38,13 +37,14 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
- ctx.sessionProjections.register(agentPresetProjectionDefinition)
- installModelSelectionProjection(ctx)
+ await ctx.sessionProjections.register(agentPresetProjectionDefinition)
ctx.provide('agentDefaultModel', {
currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
saveSelection: () => Promise.resolve(),
} as never)
- return { ctx, agents: new ApiSessionAgentController(ctx) }
+ const agents = new ApiSessionAgentController(ctx)
+ await agents.initialize()
+ return { ctx, agents }
}
function header(id: string, cwd: string | null = '/workspace'): SessionHeader {
@@ -288,14 +288,6 @@ describe('ApiSession Agent lookup and recovery', () => {
})
describe('ApiSession model selection', () => {
- it('requires the model-selection projection', async () => {
- const { ctx, agents } = await harness()
- const live = agent(ctx, header('missing-model-projection'))
- vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
-
- expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection')
- })
-
it('reads a reasoning-free request and consumes only the exact pending selection', async () => {
const { ctx, agents } = await harness()
const logged = agent(ctx, header('logged-model'))
diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
index 5a4d946bf0..ff5f980250 100644
--- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
+++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
@@ -38,7 +38,7 @@ async function commandHarness(
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
- ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+ await ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const sessionId = SessionId('commands-session')
const ancestor = Session.create(SessionId('ancestor'))
ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts
index 43d1394851..45d3a523ec 100644
--- a/packages/api/session-controller/tests/controller.host.spec.ts
+++ b/packages/api/session-controller/tests/controller.host.spec.ts
@@ -52,7 +52,7 @@ describe('SessionController facade', () => {
bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }),
retirePrompt: () => {},
} as never)
- const controller = createSessionTestController(ctx, defaults)
+ const controller = await createSessionTestController(ctx, defaults)
const status = vi.fn()
const failure = vi.fn()
const activity = vi.fn()
@@ -157,7 +157,7 @@ describe('SessionController facade', () => {
events: [],
}),
}) as never)
- const controller = createSessionTestController(ctx, defaults)
+ const controller = await createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const apiError = vi.fn()
ctx.on('api-session/error', apiError)
@@ -213,7 +213,7 @@ describe('SessionController facade', () => {
events: [],
}),
}) as never)
- const controller = createSessionTestController(ctx, defaults)
+ const controller = await createSessionTestController(ctx, defaults)
const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents
const started = Promise.withResolvers()
const release = Promise.withResolvers()
diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts
index eefea76713..eeae34ff03 100644
--- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts
+++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts
@@ -9,6 +9,7 @@ import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { scheduler } from 'node:timers/promises'
import SessionController from '../src/index.ts'
+import { createSessionTestController, TEST_IMAGE_LIMITS } from './test-remote.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiSessionList } from '../src/list.ts'
@@ -47,7 +48,9 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
+ ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never)
const list = new ApiSessionList(ctx, workSliceMs)
+ await list.initialize()
const clock = { now: 0 }
const prepared = new Map()
const agents = new Map()
@@ -185,7 +188,10 @@ describe('Session-list work slices', () => {
h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never)
h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never)
h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never)
- const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false })
+ const controller = await createSessionTestController(h.ctx, {
+ nativeOpen: false, listWorkSliceMs: 4, canOpenPath: () => false,
+ defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: '/tmp',
+ })
const yieldWork = vi.spyOn(scheduler, 'yield')
const result = await controller.list({}, new AbortController().signal)
expect(result.items).toHaveLength(3)
diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts
index 0a14c90f0a..ce51110107 100644
--- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts
+++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts
@@ -49,8 +49,8 @@ async function harness(withPersistence = true) {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
- ctx.sessionProjections.register(titleProjectionDefinition)
+ await ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
+ await ctx.sessionProjections.register(titleProjectionDefinition)
if (withPersistence) {
await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' })
const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing'))
@@ -58,7 +58,7 @@ async function harness(withPersistence = true) {
expect(list).not.toHaveBeenCalled()
})
}
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test' }),
cwd: root,
})
diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts
index a6b7a033dd..dd1a68f610 100644
--- a/packages/api/session-controller/tests/session-cold.host.spec.ts
+++ b/packages/api/session-controller/tests/session-cold.host.spec.ts
@@ -97,7 +97,7 @@ describe('sessions.list cold merge', () => {
const stat = vi.fn()
const open = vi.fn()
ctx.provide('sessionPersistence', { list, stat, open } as never)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -147,7 +147,7 @@ describe('sessions.list cold merge', () => {
? { asOfSeq: 2, values: { title: 'Cached predecessor title' } }
: undefined,
} as never)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -209,7 +209,7 @@ describe('sessions.list cold merge', () => {
},
cachedPredecessorTitle: () => undefined,
} as never)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.list(request({}))
expect(response.ok).toBe(true)
@@ -247,7 +247,7 @@ describe('attached updatedAt tracks human prompts', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await new Promise(resolve => setTimeout(resolve, 0))
// Old work, resumed just now: the log tail would report the pickup.
@@ -306,7 +306,7 @@ describe('cold history recovery view', () => {
list: () => Promise.resolve([structuredClone(meta)]),
inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }),
})
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await remote.page({
address: { kind: 'session', sessionId },
@@ -365,7 +365,7 @@ describe('Remote Agent and Session lookup policy', () => {
}),
})
const resume = vi.spyOn(ctx.agents, 'resume')
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -391,7 +391,7 @@ describe('Remote Agent and Session lookup policy', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -427,7 +427,7 @@ describe('Remote Agent and Session lookup policy', () => {
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
- createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -469,7 +469,7 @@ describe('Remote Agent and Session lookup policy', () => {
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
- createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -511,7 +511,7 @@ describe('Remote Agent and Session lookup policy', () => {
return { agent: published, dispose: () => Promise.resolve() }
})
const defaultLookup = ctx.typert.lookups.get('agent')
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -562,8 +562,8 @@ describe('subagent ownership fence', () => {
inspect,
})
const resume = vi.spyOn(ctx.agents, 'resume')
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
- ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ await ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const history = await new SessionHistoryController(
ctx,
@@ -629,7 +629,7 @@ describe('subagent ownership fence', () => {
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const prompt = await remote.prompt(promptRequest({
sessionId,
@@ -669,7 +669,7 @@ describe('subagent ownership fence', () => {
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const stopped = await remote.cancel(request({ sessionId: originChild.id }))
expect(stopped.ok).toBe(false)
@@ -719,7 +719,7 @@ describe('subagent ownership fence', () => {
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
await ctx.agents.register(agent)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.prompt(promptRequest({
sessionId: agent.id,
@@ -740,7 +740,7 @@ describe('subagent ownership fence', () => {
id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup,
} as unknown as Agent
await ctx.agents.register(agent)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -805,7 +805,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await remote.list(request({}))
expect(listed.ok).toBe(true)
@@ -833,7 +833,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
stat,
inspect,
})
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await remote.page({
address: { kind: 'session', sessionId: sid('session-missing') },
@@ -883,7 +883,7 @@ describe('sessions.prompt synchronous rejection', () => {
provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'],
}),
} as never)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/tmp',
})
@@ -960,7 +960,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await remote.prompt(promptRequest({
@@ -1001,7 +1001,7 @@ describe('sessions.prompt synchronous rejection', () => {
await ctx.agents.register(child)
throw new Error('session id already published')
})
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' }))
expect(selection.ok).toBe(false)
diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts
index e6ba8321ce..87fbd2456e 100644
--- a/packages/api/session-controller/tests/session-fork.host.spec.ts
+++ b/packages/api/session-controller/tests/session-fork.host.spec.ts
@@ -90,7 +90,7 @@ async function liveAgent(
return session
}
-const remote = (ctx: Context) => createSessionTestRemote(ctx, {
+const remote = async (ctx: Context) => await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
})
@@ -120,7 +120,7 @@ describe('sessions.fork', () => {
}) as never)
const open = vi.spyOn(ctx.sessionPersistence, 'open')
- const response = await remote(ctx).fork({
+ const response = await (await remote(ctx)).fork({
sessionId: source.id,
...allowMigration === undefined ? {} : { allowMigration },
})
@@ -150,7 +150,7 @@ describe('sessions.fork', () => {
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const create = vi.spyOn(ctx.agents, 'create')
- await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
+ await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } },
})
expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId)
@@ -171,7 +171,7 @@ describe('sessions.fork', () => {
const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read')))
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
- const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false })
+ const result = await (await remote(ctx)).fork({ sessionId: source.id, allowMigration: false })
if (!result.ok) throw result.error
expect(stat).not.toHaveBeenCalled()
@@ -199,7 +199,7 @@ describe('sessions.fork', () => {
return finish.promise
})
ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never)
- const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false })
+ const operation = (await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })
try {
await entered.promise
const live = ctx.sessions.create(sourceId, { meta: source.header })
@@ -229,7 +229,7 @@ describe('sessions.fork', () => {
const observe = vi.spyOn(ctx.sessionQuery, 'observeSession')
const create = vi.spyOn(ctx.agents, 'create')
- await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
+ await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({
ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' },
})
@@ -262,9 +262,9 @@ describe('sessions.fork', () => {
expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } })
source.inbox.append('next-turn', message('queued after compaction'))
- const response = await createSessionTestRemote(ctx, {
+ const response = await (await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
- }).fork({ sessionId: source.id })
+ })).fork({ sessionId: source.id })
if (!response.ok) throw response.error
const child = ctx.agents.get(response.value.sessionId)!
@@ -304,9 +304,9 @@ describe('sessions.fork', () => {
}
const original = source.session.snapshotEvents()
const atSeq = anchor === 'omitted' ? undefined : boundary
- const response = await createSessionTestRemote(ctx, {
+ const response = await (await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp',
- }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
+ })).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) })
if (!response.ok) throw response.error
const child = ctx.agents.get(response.value.sessionId)!
const requestCount = adapter.requests.length
@@ -327,7 +327,7 @@ describe('sessions.fork', () => {
it('cuts exactly at a completed turn and records lineage and cwd', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-source', 2)
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: 2 }))
expect(response.ok ? null : response.error).toBeNull()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
@@ -369,7 +369,7 @@ describe('sessions.fork', () => {
root: { header: owner.header, live: true, persisted: false },
})
- const response = await remote(ctx).fork(request({ sessionId: grandchild.id }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: grandchild.id }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
@@ -424,7 +424,7 @@ describe('sessions.fork', () => {
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
- const response = await remote(ctx).fork(request({ sessionId: sourceId }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: sourceId }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
@@ -441,7 +441,7 @@ describe('sessions.fork', () => {
it('uses the latest completed prefix only when atSeq is omitted', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-tail', 2, 'open')
- const proxy = remote(ctx)
+ const proxy = await remote(ctx)
const omitted = await proxy.fork(request({ sessionId: source.id }))
expect(omitted.ok).toBe(true)
if (omitted.ok) {
@@ -464,7 +464,7 @@ describe('sessions.fork', () => {
it('rejects an omitted boundary when no turn has completed', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-never-completed', 0, 'open')
- const response = await remote(ctx).fork(request({ sessionId: source.id }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id }))
expect(response).toMatchObject({
ok: false,
error: { code: 'session/fork-unavailable', details: { sessionId: source.id } },
@@ -481,7 +481,7 @@ describe('sessions.fork', () => {
const inherited = source.snapshotEvents()
if (openNextTurn) source.append('turn/start', { turn: 2 })
- const response = await remote(ctx).fork({ sessionId: source.id })
+ const response = await (await remote(ctx)).fork({ sessionId: source.id })
if (!response.ok) throw response.error
const child = ctx.sessions.get(response.value.sessionId)!
@@ -496,7 +496,7 @@ describe('sessions.fork', () => {
it('rejects invalid fork anchors before reading or creating a Session', async () => {
const ctx = await composed()
- const proxy = remote(ctx)
+ const proxy = await remote(ctx)
for (const atSeq of [-1, 0.5]) {
await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq })))
@@ -510,7 +510,7 @@ describe('sessions.fork', () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted')
const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: anchor }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
@@ -527,7 +527,7 @@ describe('sessions.fork', () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-open', 1, 'open')
const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.sessions.get(response.value.sessionId)
@@ -548,7 +548,7 @@ describe('sessions.fork', () => {
const boundary = source.snapshotEvents().at(-1)?.seq ?? 0
source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } })
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
@@ -604,7 +604,7 @@ describe('sessions.fork', () => {
}) as never)
const resume = vi.spyOn(ctx.agents, 'resume')
- const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: sourceId, atSeq: 4 }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
@@ -656,7 +656,7 @@ describe('sessions.fork', () => {
reason: 'change',
})
source.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
- const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
+ const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary.seq }))
expect(response.ok ? undefined : response.error).toBeUndefined()
if (!response.ok) return
const child = ctx.agents.get(response.value.sessionId)
diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
index 86ed42a678..bf0c163914 100644
--- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts
+++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts
@@ -719,7 +719,7 @@ describe('Session history raw journal', () => {
it('serves raw call and result entries without parsing tool arguments', async () => {
const { ctx } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const start = session.append('turn/start', { turn: 1 })
const call = session.append('tool/call', {
@@ -760,7 +760,7 @@ describe('Session history raw journal', () => {
])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => {
const { ctx } = await harness()
onTestFinished(() => ctx.fiber.dispose())
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const starts: SessionEvent[] = []
const messages: SessionEvent[][] = []
@@ -818,7 +818,7 @@ describe('Session history raw journal', () => {
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
const first = appendUserText(session, 'first prompt')
@@ -872,7 +872,7 @@ describe('Session history raw journal', () => {
it('paginates a message with a large embedded stream without expanding physical records', async () => {
const { ctx } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
session.append('turn/start', { turn: 1 })
session.append('step/start', { turn: 1, step: 1 })
@@ -910,7 +910,7 @@ describe('Session history raw journal', () => {
it('keeps an earlier declared source on the same message-aligned page', async () => {
const { ctx } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const source = session.append('request/context', { provider: 'p', model: 'm' })
const laterSource = session.append('request/context', { provider: 'p', model: 'm' })
@@ -931,7 +931,7 @@ describe('Session history raw journal', () => {
it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => {
const { ctx } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } })
const callId = ToolCallId('packed-call')
const attempt = session.append('assistant/attempt', {
diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts
index fce236d3e3..7ea81ea4bc 100644
--- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts
+++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts
@@ -25,7 +25,7 @@ async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; att
await ctx.plugin(AgentRegistry)
return {
ctx,
- remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
+ remote: await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
attach: async (session) => {
await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},
diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts
index e852460e93..6af6983f70 100644
--- a/packages/api/session-controller/tests/session-models.host.spec.ts
+++ b/packages/api/session-controller/tests/session-models.host.spec.ts
@@ -183,7 +183,7 @@ describe('Web session model selection', () => {
ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never)
const followup = vi.fn()
Object.assign(agent, { followup })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -251,7 +251,7 @@ describe('Web session model selection', () => {
const steer = vi.fn()
const followup = vi.fn()
Object.assign(agent, { steer, followup })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -280,7 +280,7 @@ describe('Web session model selection', () => {
it('allows a text-only selection while durable or pending images remain available for later models', async () => {
const { ctx, agent, sessionId } = await harness()
registerTextOnly(ctx)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -318,7 +318,7 @@ describe('Web session model selection', () => {
}
const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) }))
ctx.provide('attachments', { readImage } as never)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -394,7 +394,7 @@ describe('Web session model selection', () => {
value: { providers: { custom: { apiKeyEnv: 'CUSTOM_API_KEY', models: [] } } },
}])
await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'saved' })
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp',
})
await controller.initializeDefaultModel()
@@ -411,7 +411,7 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await remote.modelCatalog())
expect(currentSelection(ctx, sessionId)).toEqual({
@@ -460,7 +460,7 @@ describe('Web session model selection', () => {
return Promise.reject('string catalog failure')
}
}('String Failure', []))
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -488,7 +488,7 @@ describe('Web session model selection', () => {
it('rejects unlisted models and switches available models only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
- const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
+ const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -567,7 +567,7 @@ describe('Web session model selection', () => {
it('reads the Agent default live for a session whose log names no selection', async () => {
const { ctx, sessionId } = await harness()
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
@@ -589,7 +589,7 @@ describe('Web session model selection', () => {
model: 'deepseek-chat',
})
let stored = { provider: 'deepseek-official', model: 'deepseek-chat' }
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
})
@@ -607,12 +607,14 @@ describe('Web session model selection', () => {
reasoningEffort: ReasoningEffortId('high'),
adapterDefaults: { reasoningEffort: true },
})
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }),
cwd: '/tmp',
})
- expect(new ApiSessionAgentController(ctx).selectionFor(agent).current)
+ const controller = new ApiSessionAgentController(ctx)
+ await controller.initialize()
+ expect(controller.selectionFor(agent).current)
.toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' })
await ctx.fiber.dispose()
})
@@ -627,7 +629,7 @@ describe('Web session model selection', () => {
await Promise.allSettled([...operations, ...saves])
await ctx.fiber.dispose()
})
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: () => {
saves.push(release.promise)
@@ -653,7 +655,7 @@ describe('Web session model selection', () => {
onTestFinished(() => { warn.mockRestore() })
const saved: unknown[] = []
let reject = false
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
saveDefaultModelSelection: (selection) => {
saved.push(selection)
@@ -692,7 +694,7 @@ describe('Web session model selection', () => {
const { ctx, sessionId, agent } = await harness()
const followup = vi.fn()
Object.assign(agent, { followup })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp',
})
const list = vi.spyOn(ctx.llm, 'listModels')
@@ -716,7 +718,7 @@ describe('Web session model selection', () => {
}())
try {
await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp',
})
expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' }))
@@ -730,7 +732,7 @@ describe('Web session model selection', () => {
const { ctx, sessionId, agent } = await harness()
const followup = vi.fn()
Object.assign(agent, { followup })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
})
@@ -752,7 +754,7 @@ describe('Web session model selection', () => {
it('admits a removed model without changing its saved selection', async () => {
const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp',
})
const events = [...agent.session.snapshotEvents()]
@@ -768,7 +770,7 @@ describe('Web session model selection', () => {
it('serves a session and its catalog when the stored default names a route that is gone', async () => {
const { ctx, sessionId } = await harness()
- createSessionTestRemote(ctx, {
+ await createSessionTestRemote(ctx, {
// What a Models-page removal leaves behind: the settings document still
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
@@ -816,7 +818,7 @@ describe('Web session model selection', () => {
}, AttachmentStore.prototype) as never)
const followup = vi.fn()
Object.assign(agent, { followup })
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
})
@@ -887,7 +889,7 @@ it('initializes the account model without reasoning metadata and rejects an empt
const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [
{ provider: 'deepseek-account', id: 'basic', name: 'Basic' },
]))
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp',
})
await controller.initializeDefaultModel()
diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
index e8216b5214..a2ab39254a 100644
--- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
+++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts
@@ -23,7 +23,7 @@ async function context(): Promise {
describe('session/openWorkspacePath', () => {
it('reports the deployment opener capability independently of a Session', async () => {
const ctx = await context()
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
canOpenPath: () => false,
@@ -33,21 +33,21 @@ describe('session/openWorkspacePath', () => {
})
it('derives opener availability from config, an injected opener, or the platform probe', async () => {
- const configured = createSessionTestRemote(await context(), {
+ const configured = await createSessionTestRemote(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
nativeOpen: false,
})
await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
- const injected = createSessionTestRemote(await context(), {
+ const injected = await createSessionTestRemote(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath: () => Promise.resolve(),
})
await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true })
- const detected = createSessionTestRemote(await context(), {
+ const detected = await createSessionTestRemote(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
})
@@ -57,7 +57,7 @@ describe('session/openWorkspacePath', () => {
it('hands a Client-resolved workspace path to the Host opener unchanged', async () => {
const ctx = await context()
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath,
@@ -73,7 +73,7 @@ describe('session/openWorkspacePath', () => {
it('normalizes relative and absolute Host-resolvable paths', async () => {
const ctx = await context()
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath,
@@ -87,7 +87,7 @@ describe('session/openWorkspacePath', () => {
it('rejects empty paths before opening anything', async () => {
const ctx = await context()
const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath,
@@ -102,7 +102,7 @@ describe('session/openWorkspacePath', () => {
const ctx = await context()
const openPath = vi.fn((_path: string, _signal: AbortSignal) =>
Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text')))
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath,
@@ -129,7 +129,7 @@ describe('session/openWorkspacePath', () => {
throw new Error('opening stopped')
})
.mockRejectedValueOnce('desktop unavailable')
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
cwd: '/default',
openPath,
@@ -150,7 +150,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def
const ctx = await context()
const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {})
const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {})
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
})
try {
@@ -167,7 +167,7 @@ it('uses the native reveal adapter without a test override and respects unsuppor
const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined)
const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null)
try {
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
})
expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null })
@@ -183,7 +183,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat
onTestFinished(() => { mapping.mockRestore() })
const openPath = vi.fn(async () => {})
const revealPath = vi.fn(async () => {})
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
})
await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal))
@@ -197,7 +197,7 @@ it('rejects a filesystem mapping that resolves to another process path', async (
const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html')
onTestFinished(() => { mapping.mockRestore() })
const openPath = vi.fn(async () => {})
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath,
})
await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal))
@@ -209,7 +209,7 @@ it('dispatches default-app opening to the association adapter', async () => {
const ctx = await context()
const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined)
onTestFinished(() => { open.mockRestore() })
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
})
const signal = new AbortController().signal
@@ -223,7 +223,7 @@ it('uses the same path verification for handler queries and explicit application
const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }]
const fileApplications = vi.fn(async () => applications)
const openFileApplication = vi.fn(async () => {})
- const controller = createSessionTestController(ctx, {
+ const controller = await createSessionTestController(ctx, {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
fileApplications, openFileApplication,
})
@@ -242,11 +242,11 @@ it('uses the same path verification for handler queries and explicit application
it('avoids desktop queries when unavailable and rejects an empty query path', async () => {
const fileApplications = vi.fn(async () => [])
- const unavailable = createSessionTestController(await context(), {
+ const unavailable = await createSessionTestController(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications,
})
expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([])
- const available = createSessionTestController(await context(), {
+ const available = await createSessionTestController(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications,
})
await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' })
@@ -262,7 +262,7 @@ it('returns bounded query failures and classifies cancellation through the Remot
lifetime.abort()
throw new Error('native query stopped')
})
- const remote = createSessionTestRemote(await context(), {
+ const remote = await createSessionTestRemote(await context(), {
defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications,
})
await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({
diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts
index 5406d235f8..e39c7ea7d2 100644
--- a/packages/api/session-controller/tests/session-presets.host.spec.ts
+++ b/packages/api/session-controller/tests/session-presets.host.spec.ts
@@ -75,11 +75,11 @@ async function harness(presets?: readonly string[]) {
},
}
ctx.agents.setFactory(factory)
- const remote = createSessionTestRemote(ctx, {
+ const remote = await createSessionTestRemote(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
})
- if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition)
+ if (presets !== undefined) await ctx.sessionProjections.register(agentPresetProjectionDefinition)
return { ctx, remote }
}
diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts
index ed4f4a6b0c..0b565dfd33 100644
--- a/packages/api/session-controller/tests/session-projections.host.spec.ts
+++ b/packages/api/session-controller/tests/session-projections.host.spec.ts
@@ -160,7 +160,7 @@ function seedMessages(session: Session, count: number): void {
}
}
-const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => {
@@ -182,7 +182,7 @@ describe('session.history projections block', () => {
},
})
- const snapshot = await opening(remote(ctx), child.id)
+ const snapshot = await opening(await remote(ctx), child.id)
expect(snapshot.header).toEqual({
version: SESSION_FORMAT_VERSION,
@@ -197,7 +197,7 @@ describe('session.history projections block', () => {
it('tracks pending and used model selections across repeated request headers', async () => {
const { ctx, session } = await harness(true)
- remote(ctx)
+ await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const selected = { provider: 'p', model: 'next' }
session.append('model/selection', selected)
@@ -225,9 +225,9 @@ describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
+ await ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 3)
- const snapshot = await opening(remote(ctx), session.id)
+ const snapshot = await opening(await remote(ctx), session.id)
const { records, projections } = snapshot
expect(projections.asOfSeq).toBe(session.seq - 1)
expect(projections.values['test/last-user']).toEqual({ text: 'm2' })
@@ -257,7 +257,7 @@ describe('session.history projections block', () => {
list: () => Promise.resolve([meta]),
inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }),
}) as never)
- const snapshot = await opening(remote(ctx), coldId)
+ const snapshot = await opening(await remote(ctx), coldId)
expect(snapshot.projections.values.inbox).toEqual({
'next-turn': [message],
@@ -269,7 +269,7 @@ describe('session.history projections block', () => {
it('removes claimed steering from the pending Inbox projection immediately', async () => {
const { ctx, session, claim } = await harness(true)
- const proxy = remote(ctx)
+ const proxy = await remote(ctx)
const message = createUserMessage({
content: [{ type: 'text', text: 'apply this now' }],
source: { kind: 'user' },
@@ -309,10 +309,10 @@ describe('session.history projections block', () => {
it('returns a complete current replacement cut on each follow generation', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
+ await ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 2)
- const snapshot = await opening(remote(ctx), session.id)
+ const snapshot = await opening(await remote(ctx), session.id)
expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1])
expect(snapshot.projections.asOfSeq).toBe(1)
@@ -323,9 +323,9 @@ describe('session.history projections block', () => {
it('projects an empty log at cursor -1', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
+ await ctx.sessionProjections.register(lastUserUnit())
- const snapshot = await opening(remote(ctx), session.id)
+ const snapshot = await opening(await remote(ctx), session.id)
expect(snapshot.records).toEqual([])
expect(snapshot.projections.asOfSeq).toBe(-1)
@@ -350,7 +350,7 @@ describe('session.history projections block', () => {
saveImage(): Promise { return Promise.reject(new Error('unused')) }
readImage(): Promise { return Promise.reject(new Error('unused')) }
})
- const gateway = remote(ctx)
+ const gateway = await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
seedMessages(session, 2)
const snapshot = await opening(gateway, session.id)
@@ -377,18 +377,18 @@ describe('session.history projections block', () => {
await expect(extra).resolves.toEqual({ done: true, value: undefined })
})
- it('leaves the imageLimits key absent while no attachment service is composed', async () => {
+ it("publishes image limits from the Controller's attachment dependency", async () => {
const { ctx, session } = await harness(true)
seedMessages(session, 1)
- const snapshot = await opening(remote(ctx), session.id)
- expect('imageLimits' in snapshot.projections.values).toBe(false)
+ const snapshot = await opening(await remote(ctx), session.id)
+ expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits)
})
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
+ await ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 5)
- const older = await page(remote(ctx), request({
+ const older = await page(await remote(ctx), request({
sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2,
}))
expect(older.ok).toBe(true)
@@ -399,7 +399,7 @@ describe('session.history projections block', () => {
it('serves no block when the composition has no projection registry', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 2)
- const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
+ const response = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 }))
expect(response.ok).toBe(true)
if (!response.ok) throw new Error('unreachable')
expect('projections' in response.value).toBe(false)
@@ -407,8 +407,8 @@ describe('session.history projections block', () => {
it('never exposes a host-only unit through history, listing, or push frames', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(internalCountUnit())
- const proxy = remote(ctx)
+ await ctx.sessionProjections.register(internalCountUnit())
+ const proxy = await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const abort = new AbortController()
const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]()
@@ -438,13 +438,13 @@ describe('session.history projections block', () => {
it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => {
const { ctx, session } = await harness(true)
- const dispose = ctx.sessionProjections.register(lastUserUnit())
+ const dispose = await ctx.sessionProjections.register(lastUserUnit())
seedMessages(session, 1)
- const proxy = remote(ctx)
+ const proxy = await remote(ctx)
const before = await opening(proxy, session.id)
expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' })
- dispose()
+ await dispose.dispose()
const after = await opening(proxy, session.id)
// The registry stays mounted; only the disposed key leaves while the
// gateway-owned Session-list unit remains.
@@ -459,8 +459,8 @@ describe('session.history projections block', () => {
it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => {
const { ctx, session } = await harness(true)
expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false)
- const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => {
- createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+ const fiber = ctx.plugin(Object.assign(async (gatewayCtx: Context) => {
+ await createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'sessionProjections'] }))
await fiber.await()
await vi.waitFor(() => {
@@ -475,8 +475,8 @@ describe('session.history projections block', () => {
describe('session.list projections column', () => {
it('serves every already-materialized wire value from the live registry without folding', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
- const gateway = remote(ctx)
+ await ctx.sessionProjections.register(lastUserUnit())
+ const gateway = await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('turn/start', { turn: 1 })
seedMessages(session, 1)
@@ -496,8 +496,8 @@ describe('session.list projections column', () => {
const session = ctx.sessions.create(SessionId('preset-list'), {
meta: { cwd: '/workspace', agentPreset: 'standard' },
})
- ctx.sessionProjections.register(agentPresetProjectionDefinition)
- const gateway = remote(ctx)
+ await ctx.sessionProjections.register(agentPresetProjectionDefinition)
+ const gateway = await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
session.append('agent-preset/selected', { agentPreset: 'minimal' })
@@ -512,9 +512,9 @@ describe('session.list projections column', () => {
seedMessages(session, 1)
const unit = lastUserUnit()
const apply = vi.fn(unit.apply)
- ctx.sessionProjections.register({ ...unit, apply })
+ await ctx.sessionProjections.register({ ...unit, apply })
- const response = await remote(ctx).list(request({}))
+ const response = await (await remote(ctx)).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
@@ -525,7 +525,7 @@ describe('session.list projections column', () => {
it('omits the column entirely when no registry is mounted', async () => {
const { ctx, session } = await harness(false)
seedMessages(session, 1)
- const response = await remote(ctx).list(request({}))
+ const response = await (await remote(ctx)).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
@@ -555,7 +555,7 @@ describe('session.list projections column', () => {
}
: undefined),
} as never)
- const response = await remote(ctx).list(request({}))
+ const response = await (await remote(ctx)).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === coldId)
expect(row?.running).toBe(false)
@@ -580,9 +580,9 @@ describe('session.list projections column', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(privatePromptUnit())
+ await ctx.sessionProjections.register(privatePromptUnit())
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
- const gateway = remote(ctx)
+ const gateway = await remote(ctx)
await new Promise(resolve => setTimeout(resolve, 0))
const id = SessionId('session-cold-host-state')
@@ -635,7 +635,7 @@ describe('session.list projections column', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(titleProjectionDefinition)
+ await ctx.sessionProjections.register(titleProjectionDefinition)
await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 })
return ctx
}
@@ -647,7 +647,7 @@ describe('session.list projections column', () => {
let lastSeq: number | undefined
let promptTime: number | undefined
try {
- remote(first)
+ await remote(first)
await new Promise(resolve => setTimeout(resolve, 0))
let child: Session | undefined
const owner = await first.plugin(Object.assign((sessionCtx: Context) => {
@@ -679,7 +679,7 @@ describe('session.list projections column', () => {
// record, and nothing opens the log.
const second = await boot()
try {
- const gateway = remote(second)
+ const gateway = await remote(second)
await new Promise(resolve => setTimeout(resolve, 0))
const load = () => { throw new Error('a cold fork listing must not load the event log') }
second.provide('sessionPersistence', testSessionPersistence(second, {
@@ -712,7 +712,7 @@ describe('session.list projections column', () => {
ctx.provide('sessionPersistence', testSessionPersistence(ctx, {
list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }],
}) as never)
- const response = await remote(ctx).list(request({}))
+ const response = await (await remote(ctx)).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === coldId)
expect(row).toBeDefined()
@@ -721,7 +721,7 @@ describe('session.list projections column', () => {
it('a throwing column read degrades that row, never the listing', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register({
+ await ctx.sessionProjections.register({
...lastUserUnit(),
wire: {
viewSchema: z.union([z.object({ text: z.string() }), z.null()]),
@@ -729,7 +729,7 @@ describe('session.list projections column', () => {
},
})
seedMessages(session, 1)
- const response = await remote(ctx).list(request({}))
+ const response = await (await remote(ctx)).list(request({}))
if (!response.ok) throw new Error('unreachable')
const row = response.value.items.find(item => item.sessionId === session.id)
expect(row).toBeDefined()
@@ -754,8 +754,8 @@ describe('Session control projection frames', () => {
it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => {
const { ctx, session } = await harness(true)
- ctx.sessionProjections.register(lastUserUnit())
- const proxy = remote(ctx)
+ await ctx.sessionProjections.register(lastUserUnit())
+ const proxy = await remote(ctx)
// The controller's onChanged subscription lives in an inject child whose
// fiber activates asynchronously; yield until it lands before appending.
await new Promise(resolve => setTimeout(resolve, 0))
diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts
index 0e4754d8b0..c9f9502331 100644
--- a/packages/api/session-controller/tests/session-rename.host.spec.ts
+++ b/packages/api/session-controller/tests/session-rename.host.spec.ts
@@ -65,14 +65,14 @@ async function liveAgent(ctx: Context, id: string, turns: number): Promise createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
+const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
const ctx = await composed()
const source = await liveAgent(ctx, 'session-rename', 1)
- const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' }))
+ const renamed = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' new name ' }))
expect(renamed.ok).toBe(true)
if (!renamed.ok) return
expect(renamed.value.title).toBe('new name')
@@ -86,7 +86,7 @@ describe('sessions.rename', () => {
const source = await liveAgent(ctx, 'session-rename-bad', 1)
// U+200B passes a client-side trim gate but normalizes to empty host-side.
- const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ' }))
+ const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' ' }))
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error).toMatchObject({
@@ -107,7 +107,7 @@ describe('sessions.rename', () => {
const stale = await liveAgent(foreign, 'session-rename-stale', 1)
await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent)
- const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' }))
+ const response = await (await remote(ctx)).rename(request({ sessionId: stale.id, title: 'name' }))
expect(response.ok).toBe(false)
if (!response.ok) expect(response.error.code).toBe('gateway/internal')
})
@@ -116,7 +116,7 @@ describe('sessions.rename', () => {
const ctx = await composed(false)
const source = await liveAgent(ctx, 'session-no-titles', 1)
- const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' }))
+ const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: 'name' }))
expect(response.ok).toBe(false)
if (!response.ok) {
expect(response.error.code).toBe('gateway/internal')
diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts
index d8690ed0a0..b96b6e9463 100644
--- a/packages/api/session-controller/tests/session-search.host.spec.ts
+++ b/packages/api/session-controller/tests/session-search.host.spec.ts
@@ -17,7 +17,7 @@ import {
type SessionSearchHit,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
-import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts'
+import { createSessionTestRemote, testSessionPersistence, TEST_IMAGE_LIMITS } from './test-remote.ts'
import { ApiSessionList } from '../src/list.ts'
const sid = (value: string): SessionId => value as SessionId
@@ -96,7 +96,9 @@ function installSearchQuery(
describe('session.search', () => {
it('rejects search when the query service is absent', async () => {
const ctx = await baseContext()
+ ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never)
const list = new ApiSessionList(ctx, 8)
+ await list.initialize()
await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({
code: 'gateway/internal',
@@ -151,7 +153,7 @@ describe('session.search', () => {
],
}))
installSearchQuery(ctx, searchSessions)
- const remote = createSessionTestRemote(ctx, defaults)
+ const remote = await createSessionTestRemote(ctx, defaults)
const signal = new AbortController().signal
const response = await remote.search(request(' matching answer '), signal)
@@ -187,7 +189,7 @@ describe('session.search', () => {
ctx.sessions.create(sid('visible'), { meta: header('visible') })
const searchSessions = vi.fn()
installSearchQuery(ctx, searchSessions)
- const remote = createSessionTestRemote(ctx, defaults)
+ const remote = await createSessionTestRemote(ctx, defaults)
for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) {
await expect(remote.search(request(query), new AbortController().signal))
@@ -201,7 +203,7 @@ describe('session.search', () => {
const ctx = await baseContext()
const searchSessions = vi.fn()
installSearchQuery(ctx, searchSessions)
- const remote = createSessionTestRemote(ctx, defaults)
+ const remote = await createSessionTestRemote(ctx, defaults)
const response = await remote.search(
request('anything'),
@@ -235,7 +237,7 @@ describe('session.search', () => {
],
}))
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('match'),
new AbortController().signal,
)
@@ -262,7 +264,7 @@ describe('session.search', () => {
})
.mockResolvedValueOnce({ items: items.slice(19) })
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('match'),
new AbortController().signal,
)
@@ -303,7 +305,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('adaptive-page-limit'),
new AbortController().signal,
)
@@ -346,7 +348,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('endless-pages'),
new AbortController().signal,
)
@@ -411,7 +413,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('stale-restart'),
new AbortController().signal,
)
@@ -450,7 +452,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('stale-churn'),
new AbortController().signal,
)
@@ -479,7 +481,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('abort-stale'),
controller.signal,
)
@@ -500,7 +502,7 @@ describe('session.search', () => {
)))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('first-page-stale'),
new AbortController().signal,
)
@@ -524,7 +526,7 @@ describe('session.search', () => {
))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('continuation-invalid-limit'),
new AbortController().signal,
)
@@ -551,7 +553,7 @@ describe('session.search', () => {
))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('minimum-page-limit'),
new AbortController().signal,
)
@@ -577,7 +579,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('abort-invalid-limit'),
controller.signal,
)
@@ -596,7 +598,7 @@ describe('session.search', () => {
const searchSessions = vi.fn(() => Promise.resolve({ items: oversized }))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('oversized-page'),
new AbortController().signal,
)
@@ -622,7 +624,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('adapted-oversized-page'),
new AbortController().signal,
)
@@ -648,7 +650,7 @@ describe('session.search', () => {
}
installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] }))
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('bounded-snippet'),
new AbortController().signal,
)
@@ -670,7 +672,7 @@ describe('session.search', () => {
.mockResolvedValueOnce({ items: [], nextCursor: 'repeated' })
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('repeated-cursor'),
new AbortController().signal,
)
@@ -693,7 +695,7 @@ describe('session.search', () => {
.mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' })
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('repeated-lookahead-cursor'),
new AbortController().signal,
)
@@ -720,7 +722,7 @@ describe('session.search', () => {
.mockResolvedValueOnce({ items: items.slice(20) })
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('duplicate-pages'),
new AbortController().signal,
)
@@ -748,7 +750,7 @@ describe('session.search', () => {
})
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('cancel-continuation'),
controller.signal,
)
@@ -777,7 +779,7 @@ describe('session.search', () => {
}))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('large corpus'),
new AbortController().signal,
)
@@ -813,7 +815,7 @@ describe('session.search', () => {
const searchSessions = vi.fn()
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('cancel-during-visibility'),
controller.signal,
)
@@ -840,7 +842,7 @@ describe('session.search', () => {
const searchSessions = vi.fn(() => Promise.resolve({ items: [] }))
installSearchQuery(ctx, searchSessions)
- const response = await createSessionTestRemote(ctx, defaults).search(
+ const response = await (await createSessionTestRemote(ctx, defaults)).search(
request('header-only-visibility'),
new AbortController().signal,
)
@@ -856,7 +858,7 @@ describe('session.search', () => {
it('maps preflight cancellation, query cancellation, and provider failure', async () => {
const missingCtx = await baseContext()
missingCtx.sessions.create(sid('visible'), { meta: header('visible') })
- const missingApi = createSessionTestRemote(missingCtx, defaults)
+ const missingApi = await createSessionTestRemote(missingCtx, defaults)
const preAborted = new AbortController()
preAborted.abort()
const cancelledBeforeLookup = await missingApi.search(
@@ -875,7 +877,7 @@ describe('session.search', () => {
.mockRejectedValueOnce(aborted)
.mockRejectedValueOnce(new Error('database unavailable'))
installSearchQuery(ctx, searchSessions)
- const remote = createSessionTestRemote(ctx, defaults)
+ const remote = await createSessionTestRemote(ctx, defaults)
const cancelled = await remote.search(
request('first'),
diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
index de1852991a..7d7d5b384f 100644
--- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
+++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts
@@ -59,7 +59,7 @@ async function bench() {
installSessionReadTestServices(ctx)
provideWorkingDirectoryFixture(ctx)
await ctx.plugin(SubagentRuntime)
- const controller = createSessionTestController(ctx, defaults)
+ const controller = await createSessionTestController(ctx, defaults)
return { ctx, controller }
}
@@ -195,7 +195,7 @@ describe('SessionController subagent catalog', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
installSessionReadTestServices(ctx)
- const controller = createSessionTestController(ctx, defaults)
+ const controller = await createSessionTestController(ctx, defaults)
const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } })
const expected = ctx.sessionProjections.snapshot(session)
expect(expected.values.subagentCatalog).toBeUndefined()
diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts
index 71db06b22b..3c4a7afa79 100644
--- a/packages/api/session-controller/tests/test-remote.ts
+++ b/packages/api/session-controller/tests/test-remote.ts
@@ -2,7 +2,7 @@
import type { SessionControllerInternals } from '../src/index.ts'
import { SessionLogOffset } from '@deepseek-ai/dsh-session'
-import type { Context } from '@deepseek-ai/cordis'
+import { Service, type Context } from '@deepseek-ai/cordis'
import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
import type {
AdmittedPromptContentPart,
@@ -103,11 +103,13 @@ export interface TestSessionRemoteDefaults {
readonly openFileApplication?: SessionControllerInternals['openFileApplication']
readonly revealPath?: (path: string, signal: AbortSignal) => Promise
readonly canOpenPath?: () => boolean
+ readonly listWorkSliceMs?: number
}
const installed = new WeakMap()
-const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({
+/** Image policy for controller fixtures without attachment I/O. */
+export const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({
maxImageBytes: 5 * 1024 * 1024,
maxImagesPerMessage: 20,
maxMessageImageBytes: 100 * 1024 * 1024,
@@ -231,10 +233,10 @@ export function installSessionReadTestServices(ctx: Context): void {
if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx)
}
-function installControllers(
+async function installControllers(
ctx: Context,
defaults: TestSessionRemoteDefaults,
-): SessionController {
+): Promise {
const found = installed.get(ctx)
if (found !== undefined) return found
@@ -290,12 +292,18 @@ function installControllers(
}
installSessionReadTestServices(ctx)
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd)
- let controller: SessionController
+ class TestController extends SessionController {
+ initialize(): Promise {
+ return this[Service.init]()
+ }
+ }
+ let controller: TestController
try {
- controller = new SessionController(
+ controller = new TestController(
ctx,
{
...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen },
+ ...defaults.listWorkSliceMs === undefined ? {} : { listWorkSliceMs: defaults.listWorkSliceMs },
},
{
...defaults.openPath === undefined ? {} : { openPath: defaults.openPath },
@@ -305,6 +313,7 @@ function installControllers(
...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath },
},
)
+ await controller.initialize()
} finally {
cwd.mockRestore()
}
@@ -313,11 +322,11 @@ function installControllers(
}
/** Build or return the production Session Controller for a direct unit harness. */
-export function createSessionTestController(
+export async function createSessionTestController(
ctx: Context,
defaults: TestSessionRemoteDefaults,
-): SessionController {
- return installControllers(ctx, defaults)
+): Promise {
+ return await installControllers(ctx, defaults)
}
function remoteResult(
@@ -341,11 +350,11 @@ function remoteResult(
}
/** Build the generated Session Remote's unary result semantics without a carrier. */
-export function createSessionTestRemote(
+export async function createSessionTestRemote(
ctx: Context,
defaults: TestSessionRemoteDefaults,
-): TestSessionRemote {
- const direct = createSessionTestController(ctx, defaults)
+): Promise {
+ const direct = await createSessionTestController(ctx, defaults)
return {
workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult(
() => direct.workspacePathApplications(request, signal), signal,
diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts
index 890000a0ac..66b697fcef 100644
--- a/packages/api/session-controller/tests/transport.host.spec.ts
+++ b/packages/api/session-controller/tests/transport.host.spec.ts
@@ -76,7 +76,7 @@ async function setup(): Promise<{ ctx: Context; transport: SessionHistoryControl
const ctx = new Context()
await ctx.plugin(SessionStore)
installSessionReadTestServices(ctx)
- ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+ await ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() })
return { ctx, transport }
}
diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml
index 9fc0cb6eae..818bcda27b 100644
--- a/packages/context/agent-instructions/README.i18n.yaml
+++ b/packages/context/agent-instructions/README.i18n.yaml
@@ -30,8 +30,8 @@
en: 724df7ff6d1d9fea
zh: 91fb1013eb9178ec
/deepseek-ai-dsh-agent-instructions/understand-the-implementation/design-concept:
- en: 4708b51bf8fe86f7
- zh: 869fc4b1f78383ee
+ en: 566f3518b2d1d100
+ zh: c7d0a22b19e7697a
/deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map:
en: cb4c1f929ef5a835
zh: 3f0fcb7f568a0a5c
diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md
index 25c48ba1ac..e935cedf8d 100644
--- a/packages/context/agent-instructions/README.md
+++ b/packages/context/agent-instructions/README.md
@@ -85,7 +85,7 @@ This section explains the design decisions behind the plugin; the observable beh
### Design concept
-The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim.
+The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources.
### Source map
diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md
index a87cfae53a..cf193f2a28 100644
--- a/packages/context/agent-instructions/README.zh.md
+++ b/packages/context/agent-instructions/README.zh.md
@@ -85,7 +85,7 @@ export interface Config {
### 设计理念
-该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。
+该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。
### 源码地图
diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts
index 74d6e5fa22..b32a5e6bb7 100644
--- a/packages/context/agent-instructions/src/index.ts
+++ b/packages/context/agent-instructions/src/index.ts
@@ -9,6 +9,7 @@
* @module @deepseek-ai/dsh-agent-instructions
*/
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-working-directory'
import type { Context } from '@deepseek-ai/cordis'
import { isDeepStrictEqual } from 'node:util'
@@ -23,7 +24,6 @@ import { trimmedInstructionDigest } from './digest.ts'
import {
applyInstructionVersionUpdates,
baselineInstructionState,
- name,
reconcileInstructionContext,
agentInstructionsMessage,
type InstructionVersionCache,
@@ -31,9 +31,9 @@ import {
} from './state.ts'
import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, type AgentInstructionChange } from './render.ts'
-export { Config, name }
-/** Services required by workspace instruction projection. */
-export const inject = ['sessionProjections', 'workingDirectory']
+export { Config }
+export { name } from './state.ts'
+export type { AgentInstructionSource } from './state.ts'
export {
discoverBaselineInstructionFiles,
loadBaselineInstructions,
@@ -45,6 +45,8 @@ export type {
export { renderAgentInstructions } from './render.ts'
export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts'
+export const inject = ['sessionProjections', 'workingDirectory']
+
function visibleBaselineSource(
agent: Agent,
authorityMessages: readonly UserMessage[],
@@ -83,7 +85,13 @@ function filePathFromExecution(exec: ToolExecution): string | undefined {
return filePath.length > 0 ? filePath : undefined
}
-export function apply(ctx: Context, config: Config): void {
+/** Register the agent-instructions plugin after its projections are ready.
+ * @param ctx - plugin context.
+ * @param config - workspace instruction policy.
+ * @returns when the plugin is ready.
+ */
+export async function apply(ctx: Context, config: Config): Promise {
+ const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
const resolved: ResolvedConfig = resolveConfig(config)
const instructionVersions: InstructionVersionCache = new WeakMap()
const baselinePreparations = new WeakMap {
- const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary')
- if (boundary === undefined) {
- throw new Error('agent-instructions requires the turnBoundary session projection')
- }
+ const boundary = turnBoundary.stateOf(session)
return boundary.openTurnStartSeq !== null
&& boundary.lastStepBoundary?.kind === 'start'
&& boundary.lastStepBoundary.seq > boundary.openTurnStartSeq
diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
index 721866df73..a4df1f6d21 100644
--- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts
+++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts
@@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
+import type { Config as AgentInstructionsConfig } from '@deepseek-ai/dsh-agent-instructions'
import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions'
import LlmRuntime, { createUserMessage, ToolCallId, type Message, type MessageSource, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -236,19 +237,19 @@ class BlockingReadFileSystem extends RecordingFileSystem {
}
}
-async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructions.Config): Promise>> {
+async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise>> {
if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx)
if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
return ctx.plugin(AgentInstructions, config)
}
-async function mountAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> {
+async function mountAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> {
await ctx.plugin(LocalFileSystem, { cwd: '/' })
return mountAgentInstructionsPlugin(ctx, config)
}
-async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> {
+async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(LocalFileSystem, { cwd: '/' })
@@ -1234,7 +1235,7 @@ describe('workspace context request injection', () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
- await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructions.Config)).rejects.toThrow(/maxBytes/)
+ await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructionsConfig)).rejects.toThrow(/maxBytes/)
})
it('mounts without requiring a filesystem provider', async () => {
@@ -1251,28 +1252,6 @@ describe('workspace context request injection', () => {
expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory'])
})
- it('rejects a file-touch projection when the turn boundary unit is absent', async () => {
- const ctx = new Context()
- provideWorkingDirectoryFixture(ctx)
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(AgentInstructions, { maxBytes: 65536 })
- const exec = stubToolExecution({
- callId: ToolCallId('missing-turn-boundary'),
- name: 'read',
- arguments: { file_path: 'file.txt' },
- agent: await stubAgent('/virtual/repo'),
- signal: testToolSignal,
- })
-
- expect(() => {
- ctx.emit('tools/result', exec, {
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- value: null,
- })
- }).toThrow('agent-instructions requires the turnBoundary session projection')
- })
-
it('does not inject baseline context when no filesystem provider is present', async () => {
const ctx = new Context()
provideWorkingDirectoryFixture(ctx)
diff --git a/packages/context/harness-dev-context/README.i18n.yaml b/packages/context/harness-dev-context/README.i18n.yaml
index adc0b95c0e..5a9ee319b8 100644
--- a/packages/context/harness-dev-context/README.i18n.yaml
+++ b/packages/context/harness-dev-context/README.i18n.yaml
@@ -33,8 +33,8 @@
en: 4696b6c3a1f91f96
zh: 3a56d257ec768a5e
/deepseek-ai-dsh-harness-dev-context/understand-the-implementation/main-flow:
- en: f3844a1825b2e329
- zh: bdcafbcadbc6d316
+ en: 55c4cdd475f9f851
+ zh: ac4fb96e7be88810
/deepseek-ai-dsh-harness-dev-context/further-exploration:
en: f291741c499581ac
zh: 8ee0baef51e69ef0
diff --git a/packages/context/harness-dev-context/README.md b/packages/context/harness-dev-context/README.md
index 04d3925181..3d4ca78246 100644
--- a/packages/context/harness-dev-context/README.md
+++ b/packages/context/harness-dev-context/README.md
@@ -57,7 +57,7 @@ A host-only session projection, `harnessDevLegacyPrompt`, folds every committed
### Main flow
-Activation registers the projection unit, both sections, and both contexts. At each assembly the sections choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration.
+Activation awaits the projection handle before registering both sections and both contexts. At each assembly the sections read the handle to choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration.
diff --git a/packages/context/harness-dev-context/README.zh.md b/packages/context/harness-dev-context/README.zh.md
index e108c6f161..b25f5eea66 100644
--- a/packages/context/harness-dev-context/README.zh.md
+++ b/packages/context/harness-dev-context/README.zh.md
@@ -57,7 +57,7 @@ kind: "package-reference"
### 主流程
-激活时注册投影单元、两个段落与两个上下文。每次组装时,段落在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。
+激活时等待取得投影 handle,再注册两个段落与两个上下文。每次组装时,段落读取 handle,在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。
diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts
index becb4a6005..d823735a2a 100644
--- a/packages/context/harness-dev-context/src/index.ts
+++ b/packages/context/harness-dev-context/src/index.ts
@@ -124,9 +124,10 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null }
* Register the two sections, their runtime-context values, and the
* projection that keeps a Session's admitted paragraphs.
* @param ctx - plugin context with every {@link inject} service.
+ * @returns after the projection reader and prompt registrations are ready.
*/
-export function apply(ctx: Context): void {
- ctx.sessionProjections.register({
+export async function apply(ctx: Context): Promise {
+ const legacyPrompt = await ctx.sessionProjections.register({
key: LEGACY_PROMPT,
stateVersion: 1,
stateSchema: legacyPromptSchema,
@@ -135,8 +136,7 @@ export function apply(ctx: Context): void {
})
const admitted = (context: AssembleContext): LegacyPrompt => {
const session = context.agent?.session
- // This plugin registers the unit above, so its state exists for every Session.
- return session === undefined ? ADMITS_NOTHING : ctx.sessionProjections.stateOf(session, LEGACY_PROMPT) as LegacyPrompt
+ return session === undefined ? ADMITS_NOTHING : legacyPrompt.stateOf(session)
}
ctx.systemPrompt.section({
name: HARNESS_SOURCE,
diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts
index 493630a811..d1049f4203 100644
--- a/packages/context/time-context/src/index.ts
+++ b/packages/context/time-context/src/index.ts
@@ -49,7 +49,6 @@ const timeContextStateSchema = zod.object({
/** Folded time-context readings. */
type TimeContextProjection = zod.infer
-/** The agent registry that owns pre-step processing. */
export const inject = ['agents', 'sessionProjections']
/** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */
@@ -128,8 +127,9 @@ function validateRefreshInterval(refreshIntervalMs: number): void {
* @param ctx - plugin context; the listener is disposed with it.
* @param config - time zone and durable refresh scheduling configuration.
* @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context, config: Config): void {
+export async function apply(ctx: Context, config: Config): Promise {
const timeZone = config.timeZone
const refreshIntervalMs = config.refreshIntervalMs ?? 600_000
validateRefreshInterval(refreshIntervalMs)
@@ -154,7 +154,7 @@ export function apply(ctx: Context, config: Config): void {
return created
}
- ctx.sessionProjections.register({
+ const projection = await ctx.sessionProjections.register({
key: 'timeContext',
stateVersion: 2,
stateSchema: timeContextStateSchema,
@@ -189,7 +189,7 @@ export function apply(ctx: Context, config: Config): void {
const decision = await next()
if (decision.kind === 'reject' || signal.aborted) return decision
const now = Date.now()
- const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection
+ const state = projection.stateOf(agent.session)
if (refreshIntervalMs > 0) {
const lastInjection = state.lastInjectionTime
if (lastInjection != null
diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts
index 92499007d1..a795935f2e 100644
--- a/packages/context/time-context/tests/time-context.spec.ts
+++ b/packages/context/time-context/tests/time-context.spec.ts
@@ -429,10 +429,10 @@ describe('time-context projection fold edges', () => {
content: [{ type: 'text', text: 'reading' }],
source: { kind: 'time-context' },
}), { surfaceOp: 'append' })
- expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number')
+ expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toHaveProperty('lastTurnInjectionTime', expect.any(Number))
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
session.append('turn/start', { turn: 2 })
- expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({
+ expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
lastTurnInjectionTime: null,
})
})
@@ -441,7 +441,7 @@ describe('time-context projection fold edges', () => {
const { ctx } = await mount()
const session = Session.create(SessionId('end-without-start'))
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
- expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({
+ expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({
lastTurnInjectionTime: null,
})
})
diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts
index af6d636e93..fec71d8d69 100644
--- a/packages/context/tmux-context/src/index.ts
+++ b/packages/context/tmux-context/src/index.ts
@@ -36,11 +36,9 @@ declare module '@deepseek-ai/dsh-llm' {
}
}
-
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tmux-context'
-/** The agent registry that owns pre-step processing. */
export const inject = ['agents', 'sessionProjections']
/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */
@@ -223,11 +221,15 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
}
}
-export function apply(ctx: Context, config: Config): void {
+/** Register the tmux-context plugin after its projections are ready.
+ * @param ctx - plugin context.
+ * @returns when the plugin is ready.
+ */
+export async function apply(ctx: Context, config: Config): Promise {
const refreshIntervalMs = config.refreshIntervalMs
validateRefreshInterval(refreshIntervalMs)
- ctx.sessionProjections.register({
+ const projection = await ctx.sessionProjections.register({
key: 'tmuxContext',
stateVersion: 1,
stateSchema: tmuxContextStateSchema,
@@ -251,7 +253,7 @@ export function apply(ctx: Context, config: Config): void {
if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision
const bash = ctx.get('shell')
if (bash === undefined) return decision
- const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') as TmuxContextState
+ const previous = projection.stateOf(agent.session)
if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) {
const now = Date.now()
if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision
diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts
index 8b47748b03..afc6ab82a8 100644
--- a/packages/deliverables/tool-present/src/index.ts
+++ b/packages/deliverables/tool-present/src/index.ts
@@ -1,4 +1,5 @@
/** Scoped tool that declares filesystem deliveries in their owning Session. */
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-working-directory'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
@@ -9,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-session-projection'
import type { Session } from '@deepseek-ai/dsh-session'
import type { PresentedFile } from './types.ts'
-/** Stable Loader identity. */
export const name = 'tool-present'
/** Per-call delivery limit. */
@@ -23,15 +23,16 @@ export const Config: z = z.object({
maxFiles: z.number().default(8),
})
-/** Services used by the scoped delivery tool. */
export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory']
/**
* Register present with durable file references in its tool result.
* @param ctx - agent-scoped services.
* @param config - maximum files per call.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context, config: Config): void {
+export async function apply(ctx: Context, config: Config): Promise {
+ const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) {
throw new Error('present requires a positive integer maxFiles')
}
@@ -76,8 +77,8 @@ export function apply(ctx: Context, config: Config): void {
},
async execute(args, exec) {
if (exec.agent === undefined) throw new Error('present requires an agent Session')
- const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary')
- if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
+ const boundary = turnBoundary.stateOf(exec.agent.session)
+ if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn')
if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`)
const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal)
const options = { cwd, signal: exec.signal }
diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts
index e8b0bee060..7595872758 100644
--- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts
+++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts
@@ -16,7 +16,8 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js'
import SystemPrompt from './packages/core/system-prompt/lib/index.js'
import ToolRuntime from './packages/core/tools/lib/index.js'
import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js'
-import * as Present from './packages/deliverables/tool-present/lib/index.js'
+import Present from './packages/deliverables/tool-present/lib/index.js'
+import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -28,10 +29,11 @@ try {
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
- ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(Present, { maxFiles: 2 })
const scope = ctx.plugin(() => {})
const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false })
+ session.append('turn/start', { turn: 1 })
const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' }
await ctx.agents.register(owner)
const events = []
diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts
index afbbf5958a..94fae8366f 100644
--- a/packages/deliverables/tool-present/tests/present.spec.ts
+++ b/packages/deliverables/tool-present/tests/present.spec.ts
@@ -62,7 +62,7 @@ async function setup() {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalFileSystem, { cwd: root })
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
const fiber = ctx.plugin(Present, { maxFiles: 2 })
await fiber
const owner = await agent(ctx, root)
@@ -149,9 +149,12 @@ describe('present', () => {
})
-it('validates deployment limits before registering the tool', () => {
+it('validates deployment limits before registering the tool', async () => {
+ const ctx = new Context()
+ cleanups.push(() => ctx.fiber.dispose())
+ await ctx.plugin(SessionProjectionRegistry)
for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) {
- expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles')
+ await expect(Present.apply(ctx, config)).rejects.toThrow('positive integer maxFiles')
}
})
diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml
index e4f3b0666e..0dc6d22bb4 100644
--- a/packages/experimental/agent-team/README.i18n.yaml
+++ b/packages/experimental/agent-team/README.i18n.yaml
@@ -63,14 +63,14 @@
en: 6922e307e18696b4
zh: 083f4d95587a844b
/deepseek-ai-dsh-experimental-agent-team/understand-the-implementation/disposal:
- en: a12fb358183cc6d8
- zh: 7fdb0434d3c407fe
+ en: f32086839d37c9a6
+ zh: b40ab7140b314afb
/deepseek-ai-dsh-experimental-agent-team/further-exploration:
en: 020d07f96ef2077d
zh: 9e771d38a02048e1
/deepseek-ai-dsh-experimental-agent-team/further-exploration/browser-projection:
- en: 4b3033806e6db528
- zh: 53e0dfef7309840e
+ en: 5dfc9113cabec7cd
+ zh: 22eaee731ca3f1b3
/deepseek-ai-dsh-experimental-agent-team/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md
index 2d8abc7409..40872142cb 100644
--- a/packages/experimental/agent-team/README.md
+++ b/packages/experimental/agent-team/README.md
@@ -149,7 +149,7 @@ Mailbox projection and checkpoint admission preserve every decoded JSON field of
### Disposal
-The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported.
+The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. Team shutdown is independent of projection unregistration; its retained reader follows Session events while admitted work settles.
@@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t
### Browser projection
-The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types.
+The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. A failed projection rejects subsequent Team reads and mutations without appending further Team events.
The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md
index e60d93b255..815d41eb2b 100644
--- a/packages/experimental/agent-team/README.zh.md
+++ b/packages/experimental/agent-team/README.zh.md
@@ -149,7 +149,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内
### Dispose
-运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。
+运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 Team 停机与投影注销独立;在已获准操作收尾期间,保留的 reader 仍会根据 Session 事件更新读取结果。
@@ -171,7 +171,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内
### 浏览器投影
-`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。
+`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 投影失败后,后续 Team 读取和修改均报错,不再追加 Team 事件。
[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts
index 2d35933776..fd1ea84a29 100644
--- a/packages/experimental/agent-team/src/index.ts
+++ b/packages/experimental/agent-team/src/index.ts
@@ -9,8 +9,8 @@ import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal'
import { TeamActivity } from './activity.ts'
import { errorMessage, TeamError } from './error.ts'
import { TeamJournal } from './journal.ts'
-import { TeamRuntimeLifecycle } from './lifecycle.ts'
import { teamProjectionDefinition } from './projection.ts'
+import { TeamRuntimeLifecycle } from './lifecycle.ts'
import { resolveActiveMember, TeamRoster } from './roster.ts'
import type { TeamMembership } from './roster.ts'
import { TeamTaskBoard } from './task-board.ts'
@@ -68,9 +68,9 @@ export class TeamService extends Service {
private readonly activity: TeamActivity
private readonly lifecycle: TeamRuntimeLifecycle
- private readonly journal: TeamJournal
- private readonly roster: TeamRoster
- private readonly tasks: TeamTaskBoard
+ private journal!: TeamJournal
+ private roster!: TeamRoster
+ private tasks!: TeamTaskBoard
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'agentTeams')
@@ -86,25 +86,21 @@ export class TeamService extends Service {
this.activity = new TeamActivity()
this.lifecycle = new TeamRuntimeLifecycle(this.config.disposalTimeoutMs)
- this.journal = new TeamJournal(ctx, (root) => { this.activity.notify(TeamId(root.id)) })
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ const projection = await ctx.sessionProjections.register(teamProjectionDefinition)
+ this.journal = new TeamJournal(ctx, projection, (root) => { this.activity.notify(TeamId(root.id)) })
this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers)
this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks)
+ ctx.effect(() => () => this.disposeRuntime(), 'agentTeams.runtimeLifecycle()')
ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) })
ctx.on('agent/status', ({ agent }) => {
const membership = this.roster.tryMembership(agent)
if (membership !== undefined) this.activity.notify(membership.id)
})
- ctx.effect(() => {
- const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition)
- return async () => {
- try {
- await this.disposeRuntime()
- } finally {
- disposeProjection()
- }
- }
- }, 'agentTeams.runtimeLifecycle()')
for (const agent of ctx.agents.list()) this.scheduleRecovery(agent)
}
diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts
index f69ac376b8..6a6002bb72 100644
--- a/packages/experimental/agent-team/src/journal.ts
+++ b/packages/experimental/agent-team/src/journal.ts
@@ -1,9 +1,10 @@
/** Serialized Team transactions over the exact live Lead Session log. */
import type { Agent } from '@deepseek-ai/dsh-agent'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { Context } from '@deepseek-ai/cordis'
import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session'
-import type { TeamEventType, TeamState } from './projection.ts'
+import type { TeamEventType, TeamProjectionState, TeamState } from './projection.ts'
type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void
type MutableTeamEventType = 'team/member' | 'team/task'
@@ -14,10 +15,12 @@ export class TeamJournal {
/**
* @param ctx - Team service context with the injected Session service.
+ * @param projection - Team state registration owned by the Team service.
* @param onCommit - synchronous notification after the Team event flush succeeds.
*/
constructor(
private readonly ctx: Context,
+ private readonly projection: ProjectionHandle,
private readonly onCommit: (root: Agent) => void,
) {}
@@ -25,10 +28,10 @@ export class TeamJournal {
* Read authoritative Team state for one exact live Lead.
* @param root - exact live Team Lead.
* @returns current projected state selected by the Lead Team id.
+ * @throws when the durable Team projection has failed.
*/
state(root: Agent): TeamState {
- const projection = this.ctx.sessionProjections.stateOf(root.session, 'agentTeam')
- if (projection === undefined) throw new Error('Agent Teams projection is not registered')
+ const projection = this.projection.stateOf(root.session)
if (projection.failure !== undefined) throw new Error(projection.failure)
return projection
}
diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts
index 2266b41483..2de9c75ff4 100644
--- a/packages/experimental/agent-team/tests/projection-events.spec.ts
+++ b/packages/experimental/agent-team/tests/projection-events.spec.ts
@@ -90,7 +90,7 @@ describe('Agent Teams projection events', () => {
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition))
+ await ctx.sessionProjections.register(teamProjectionDefinition)
const session = ctx.sessions.create(ROOT)
const restore = (val: unknown) => ctx.sessionProjections.restore({
agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val },
@@ -359,7 +359,7 @@ describe('Agent Teams projection events', () => {
const registry = ctx.plugin(SessionProjectionRegistry)
try {
await registry
- ctx.sessionProjections.register(teamProjectionDefinition)
+ await ctx.sessionProjections.register(teamProjectionDefinition)
const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true}}') as ContentBlock
const queued = event('team/message/queued', {
version: 2, teamId: TEAM, message: message({ content: [extension] }),
@@ -546,7 +546,7 @@ describe('Agent Teams projection events', () => {
onTestFinished(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition))
+ await ctx.sessionProjections.register(teamProjectionDefinition)
const published: Array<{ session: SessionId; key: string; value: unknown }> = []
ctx.sessionProjections.onChanged((session, key, value) => {
published.push({ session: session.id, key, value })
diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts
index ec79684726..671cb281f7 100644
--- a/packages/experimental/agent-team/tests/team.spec.ts
+++ b/packages/experimental/agent-team/tests/team.spec.ts
@@ -1,3 +1,4 @@
+import { Service } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -141,19 +142,20 @@ async function waitRunning(ctx: Context, id: SessionId): Promise {
}
describe('Team identity and provisioning', () => {
- it('rejects missing and failed authoritative Team projections', async () => {
- const first = await setup([])
- const journal = teamInternals(first.ctx).journal
- const stateOf = first.ctx.sessionProjections.stateOf.bind(first.ctx.sessionProjections)
- const stateOfSpy = vi.spyOn(first.ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => (
- key === 'agentTeam' ? undefined : stateOf(session, key)
- ))
- expect(() => journal.state(first.lead)).toThrow('Agent Teams projection is not registered')
- stateOfSpy.mockImplementation((session, key) => key === 'agentTeam'
- ? { ...teamProjectionDefinition.init(session.header), failure: 'failed Team projection' }
- : stateOf(session, key))
- expect(() => journal.state(first.lead)).toThrow('failed Team projection')
- stateOfSpy.mockRestore()
+ it('rejects reads and mutations after a Team projection fails', async () => {
+ const { ctx, lead } = await setup([])
+ lead.session.append('team/message/delivered', {
+ version: 2,
+ teamId: TeamId(lead.id),
+ messageId: TeamMessageId('unqueued-message'),
+ targetId: lead.id,
+ })
+
+ expect(() => ctx.agentTeams.listMembers(lead)).toThrow('delivered before queueing')
+ const eventCount = lead.session.snapshotEvents().length
+ await expect(ctx.agentTeams.createTask(lead, { subject: 'blocked', description: 'blocked' }))
+ .rejects.toThrow('delivered before queueing')
+ expect(lead.session.snapshotEvents()).toHaveLength(eventCount)
})
it('rejects deployment limits that are not positive safe integers', async () => {
@@ -181,7 +183,11 @@ describe('Team identity and provisioning', () => {
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentService)
const lead = await ctx.agentLoop.create(SessionId('preexisting-lead'), {})
- const service = new TeamService(ctx)
+ class DirectTeamService extends TeamService {
+ initialize(): Promise { return this[Service.init]() }
+ }
+ const service = new DirectTeamService(ctx)
+ await service.initialize()
expect(service.listMembers(lead)).toEqual([expect.objectContaining({
name: 'lead',
@@ -1147,6 +1153,32 @@ describe('Team direct messages and waiting', () => {
expect(ctx.get('agentTeams')).toBeUndefined()
})
+ it.each([false, true])('settles admitted work after projection unregistration (failure: %s)', async (fails) => {
+ const { ctx, lead, teamFiber } = await setup([])
+ const internal = teamInternals(ctx)
+ const state = internal.journal.state(lead)
+ const finish = Promise.withResolvers()
+ let readDuringShutdown = false
+ void internal.lifecycle.track(finish.promise.then(() => {
+ expect(internal.journal.state(lead)).toBe(state)
+ readDuringShutdown = true
+ if (fails) throw new Error('admitted operation failed during shutdown')
+ }))
+ const disposal = teamFiber.dispose()
+ try {
+ await vi.waitFor(() => {
+ expect(internal.lifecycle.disposed).toBe(true)
+ expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam')
+ })
+ expect(readDuringShutdown).toBe(false)
+ } finally {
+ finish.resolve(undefined)
+ await disposal
+ }
+ expect(readDuringShutdown).toBe(true)
+ expect(ctx.get('agentTeams')).toBeUndefined()
+ })
+
it('closes creation admission and drains an in-flight spawn before unload completes', async () => {
const { ctx, lead, teamFiber } = await setup(['hang'])
const service = ctx.agentTeams
diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml
index aa0a5e6116..68c4c306d6 100644
--- a/packages/experimental/claude-code-mods/README.i18n.yaml
+++ b/packages/experimental/claude-code-mods/README.i18n.yaml
@@ -9,8 +9,8 @@
en: bf54b493565f1624
zh: 6f28a518f7b0dea8
/deepseek-ai-dsh-experimental-claude-code-mods/summary:
- en: 4ddb71e280b6b328
- zh: e4775593fd7f232c
+ en: e21a08cf7ad99a77
+ zh: df7634372a42d9b2
/deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents:
en: d152484eb41ac6b4
zh: 09388d293f9be9cb
diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md
index 4fd02d3ded..52dc778067 100644
--- a/packages/experimental/claude-code-mods/README.md
+++ b/packages/experimental/claude-code-mods/README.md
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
-Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference.
+Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-session-projection`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference.
## Table of Contents
diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md
index 1a2f909626..6b11df8418 100644
--- a/packages/experimental/claude-code-mods/README.zh.md
+++ b/packages/experimental/claude-code-mods/README.zh.md
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
-在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。
+在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-session-projection`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。
## 目录
diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts
index 3d6363b463..7b61a18a4c 100644
--- a/packages/experimental/claude-code-mods/src/host-ops.ts
+++ b/packages/experimental/claude-code-mods/src/host-ops.ts
@@ -9,20 +9,17 @@
*/
import type { Context } from '@deepseek-ai/cordis'
-import type { Agent } from '@deepseek-ai/dsh-agent'
-// Type-only: activate the `ctx.get('subprocess')` and `ctx.get('sessionProjections')` Context declarations.
-import type {} from '@deepseek-ai/dsh-subprocess'
import type {} from '@deepseek-ai/dsh-session-projection'
-import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types'
+import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent'
+// Type-only: activate the `ctx.get('subprocess')` Context declaration.
+import type {} from '@deepseek-ai/dsh-subprocess'
import type { CommandDefinition } from '@deepseek-ai/dsh-commands'
import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm'
import { scopeOf } from '@deepseek-ai/dsh-scope'
-import type { Session } from '@deepseek-ai/dsh-session'
import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory'
-// Type-only: merges the token-meter projection states into SessionProjectionStateMap.
-import type {} from '@deepseek-ai/dsh-token-meter'
+import { contextPressureProjectionDefinition } from '@deepseek-ai/dsh-token-meter'
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
import type { Domain } from '@deepseek-ai/dsh-storage-domain'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
@@ -161,12 +158,14 @@ function sizeOf(info: { size?: number } | undefined): number {
}
/**
- * Build the op table over the composed harness services.
+ * Build the op table with the required session projection registry and composed harness services.
* @param options - the context, aliases, limits, and registration tracking.
- * @returns engine behaviors by event name.
+ * @returns engine behaviors by event name, after both session projections are registered.
*/
-export function createHostOps(options: HostOpsOptions): OpTable {
+export async function createHostOps(options: HostOpsOptions): Promise> {
const { ctx, aliases } = options
+ const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ const contextPressure = await ctx.sessionProjections.register(contextPressureProjectionDefinition)
let storeDomain: Promise> | undefined
function store(): Promise> {
@@ -225,11 +224,6 @@ export function createHostOps(options: HostOpsOptions): OpTable {
if (agent !== undefined) options.redraw?.(agent.session.id)
}
- /** One projection's state, or undefined when the registry or the projection's owning plugin is not composed. */
- function projection(session: Session, key: K): SessionProjectionStateMap[K] | undefined {
- return ctx.get('sessionProjections')?.stateOf(session, key)
- }
-
return {
// ---- ui: no mod drawing surface exists; log-style calls reach the host log ----
'ui.log': (input, { mod }) => {
@@ -411,16 +405,14 @@ export function createHostOps(options: HostOpsOptions): OpTable {
},
'session.turns': (_input, context) => {
const agent = requireAgent(context, 'session.turns')
- const boundary = projection(agent.session, 'turnBoundary')
- if (boundary === undefined) throw new Error('$.session.turns needs the turnBoundary projection, which this deployment did not compose')
- return boundary.lastTurn
+ return turnBoundary.stateOf(agent.session).lastTurn
},
'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases),
'session.usage': (_input, context): SessionUsage => {
const agent = requireAgent(context, 'session.usage')
- const pressure = projection(agent.session, 'contextPressure')
- const tokens = pressure?.pressureTokens
- const window = pressure?.contextWindow ?? 0
+ const pressure = contextPressure.stateOf(agent.session)
+ const tokens = pressure.pressureTokens
+ const window = pressure.contextWindow ?? 0
return {
startedAt: agent.session.header.createdAt,
context: {
diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts
index 1c97870b73..f715a74d7f 100644
--- a/packages/experimental/claude-code-mods/src/index.ts
+++ b/packages/experimental/claude-code-mods/src/index.ts
@@ -10,7 +10,7 @@
* @module @deepseek-ai/dsh-experimental-claude-code-mods
*/
-import type { Context } from '@deepseek-ai/cordis'
+import { Service, type Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-working-directory'
import z from '@deepseek-ai/schemastery'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
@@ -208,22 +208,25 @@ export class ClaudeCodeMods extends TypertRemoteService {
bandRows: z.number().default(10),
})
- // Session initialization requires its directory owner; other services are read on demand.
- static inject = ['workingDirectory']
+ // Session initialization requires its directory owner and projection registry; other services are read on demand.
+ static inject = ['workingDirectory', 'sessionProjections']
- private readonly engine: ModsEngine
+ private engine!: ModsEngine
private readonly registrations = new Map void>>()
- private readonly surfaces: SurfaceTable
+ private surfaces!: SurfaceTable
- constructor(ctx: Context, config: Config) {
+ constructor(ctx: Context, private readonly config: Config) {
super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' })
+ for (const key of ['hookTimeoutMs', 'catchTimeoutMs', 'processTimeoutMs', 'bandColumns', 'bandRows'] as const) {
+ const value = config[key]
+ if (value !== undefined) assertPositive(key, value)
+ }
+ }
+
+ protected async [Service.init](): Promise {
+ const { ctx, config } = this
// The schema supplies every default; a direct construction passes the fields it needs.
const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config
- assertPositive('hookTimeoutMs', hookTimeoutMs)
- assertPositive('catchTimeoutMs', catchTimeoutMs)
- assertPositive('processTimeoutMs', processTimeoutMs)
- assertPositive('bandColumns', bandColumns)
- assertPositive('bandRows', bandRows)
const aliases = createToolNameAliases(config.toolAliases)
const callOrigins = new Map()
const modCommands = new Set()
@@ -259,7 +262,7 @@ export class ClaudeCodeMods extends TypertRemoteService {
}, 0).unref()
}
const modSubmissions = new Map()
- const ops = createHostOps({
+ const ops = await createHostOps({
ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw,
submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) },
})
diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts
index 4ee329ce99..7243febfc6 100644
--- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts
+++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts
@@ -535,7 +535,7 @@ describe('the mods API over harness services', () => {
expect(out.session).toEqual({
id: 'a1', cwd: h.workspace, root: h.workspace, model: 'mock', turns: 1,
version: MODS_API_VERSION,
- usage: { startedAt: agent.session.header.createdAt, context: { window: 0 }, rateLimits: [] },
+ usage: { startedAt: agent.session.header.createdAt, context: { tokens: 10, window: 0 }, rateLimits: [] },
messages: [
{ role: 'user', text: 'hello', toolUses: [] },
{ role: 'user', text: `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(h.workspace)}.`, toolUses: [] },
@@ -951,7 +951,7 @@ describe('loading diagnostics and configuration', () => {
})
it('is a Service plugin the Loader keeps by its default export', () => {
- expect(ClaudeCodeMods.inject).toEqual(['workingDirectory'])
+ expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'sessionProjections'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods })
expect(unwrapped).toBe(ClaudeCodeMods)
diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts
index d4bc93702d..987c2db123 100644
--- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts
+++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from '@deepseek-ai/cordis'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import ClaudeCodeMods, { defineMod } from '../src/index.ts'
@@ -32,7 +33,10 @@ describe('defineMod', () => {
const ctx = new Context()
fibers.push(ctx.fiber)
provideWorkingDirectoryFixture(ctx)
- await ctx.plugin(ClaudeCodeMods, {})
+ const bridge = ctx.plugin(ClaudeCodeMods, {})
+ expect(ctx.get('claudeCodeMods')).toBeUndefined()
+ await ctx.plugin(SessionProjectionRegistry)
+ await bridge
const fiber = await ctx.plugin(plugin, { history: 3 })
await fiber.await()
expect(seen).toEqual([{ history: 3, unit: 'tokens' }])
@@ -52,6 +56,7 @@ describe('defineMod', () => {
const ctx = new Context()
fibers.push(ctx.fiber)
provideWorkingDirectoryFixture(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(ClaudeCodeMods, {})
const fiber = ctx.plugin(slow, {})
await vi.waitFor(() => { expect(release).toBeDefined() })
@@ -69,6 +74,7 @@ describe('defineMod', () => {
const ctx = new Context()
fibers.push(ctx.fiber)
provideWorkingDirectoryFixture(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(ClaudeCodeMods, {})
const fiber = await ctx.plugin(bare, {})
await fiber.await()
diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts
index 15400980f4..750a68adf9 100644
--- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts
+++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts
@@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Message } from '@deepseek-ai/dsh-llm'
-import { SessionId } from '@deepseek-ai/dsh-session'
+import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
@@ -34,10 +35,11 @@ afterEach(async () => {
const mod: LoadedMod = { name: 'unit-mod', version: undefined, root: '/mods/unit-mod', options: {}, order: 0 }
-function setup(ctx = new Context()) {
+async function setup(ctx = new Context()) {
contexts.push(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
const registrations = new Map void>>()
- const ops = createHostOps({
+ const ops = await createHostOps({
ctx,
aliases: createToolNameAliases(),
processTimeoutMs: 5_000,
@@ -72,12 +74,16 @@ function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requ
id: 'fake',
ctx,
options: overrides.model === undefined ? {} : { model: overrides.model },
- session: {
- id: 'fake',
- header: { id: 'fake', createdAt: 1, ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd } },
+ session: Object.assign(Session.create(SessionId('fake'), [], {
+ version: SESSION_FORMAT_VERSION,
+ id: SessionId('fake'),
+ createdAt: 1,
+ isSeeded: false,
+ ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd },
+ }), {
requestHeader: () => overrides.requestModel === undefined ? undefined : { config: { provider: 'p', model: overrides.requestModel } },
deriveMessages: (): Message[] => [],
- },
+ }),
}
return facts as Agent
}
@@ -100,7 +106,7 @@ describe('values', () => {
describe('services a deployment did not compose', () => {
it('names the missing service in each call', async () => {
- const { call, ctx } = setup()
+ const { call, ctx } = await setup()
const agent = fakeAgent(ctx)
await expect(call('ui.ask', { question: 'q' }, agent)).rejects.toThrow(/dsh-user-questions/)
await expect(call('command.register', { name: 'x', description: 'd' }, agent)).rejects.toThrow(/dsh-commands/)
@@ -113,7 +119,7 @@ describe('services a deployment did not compose', () => {
await expect(call('store.get', { key: 'k' })).rejects.toThrow(/dsh-storage-domain/)
await expect(call('fs.read', { path: 'x' })).rejects.toThrow(/dsh-fs/)
await expect(call('process.run', { argv: ['ls'] })).rejects.toThrow(/dsh-subprocess/)
- await expect(call('session.turns', {}, agent)).rejects.toThrow(/turnBoundary projection/)
+ await expect(call('session.turns', {}, agent)).resolves.toBe(0)
// Without a drawn surface the ui calls that would redraw the band are plain no-ops.
await expect(call('ui.invalidate', {}, agent)).resolves.toBeUndefined()
await expect(call('ui.invalidate', {})).resolves.toBeUndefined()
@@ -123,7 +129,7 @@ describe('services a deployment did not compose', () => {
})
it('requires a session for session-bound calls and validates mod input', async () => {
- const { call } = setup()
+ const { call } = await setup()
for (const op of ['ui.ask', 'command.run', 'command.list', 'prompt.submit', 'session.id', 'session.cwd', 'session.root', 'session.model', 'session.turns', 'session.messages', 'session.usage']) {
await expect(call(op, {})).rejects.toThrow(`$.${op} needs a session, and this event has none`)
}
@@ -137,7 +143,7 @@ describe('services a deployment did not compose', () => {
describe('session facts from the agent', () => {
it('falls back from the logged route to the agent option to an empty model, and from the session cwd to the process cwd', async () => {
- const { call, ctx } = setup()
+ const { call, ctx } = await setup()
expect(await call('session.model', {}, fakeAgent(ctx, { requestModel: 'logged', model: 'opt' }))).toBe('logged')
expect(await call('session.model', {}, fakeAgent(ctx, { model: 'opt' }))).toBe('opt')
expect(await call('session.model', {}, fakeAgent(ctx))).toBe('')
@@ -157,7 +163,7 @@ describe('files through the filesystem seam', () => {
dirs.push(root)
const ctx = new Context()
await ctx.plugin(LocalFileSystem, { cwd: root })
- const { call } = setup(ctx)
+ const { call } = await setup(ctx)
provideWorkingDirectoryFixture(ctx)
writeFileSync(join(root, 'small.txt'), 'small')
writeFileSync(join(root, 'big.txt'), Buffer.alloc(FS_MAX_BYTES + 1, 97))
@@ -182,7 +188,7 @@ describe('processes through the subprocess seam', () => {
it('runs argv with an explicit cwd and env, and reports a signal-terminated child', async () => {
const ctx = new Context()
await ctx.plugin(LocalSubprocessRuntime)
- const { call } = setup(ctx)
+ const { call } = await setup(ctx)
const cwd = mkdtempSync(join(tmpdir(), 'dsh-cc-mods-proc-'))
dirs.push(cwd)
const run = await call('process.run', {
@@ -220,7 +226,7 @@ describe('the store limit', () => {
close: () => Promise.resolve(),
}
ctx.provide('storageDomain', { open: () => Promise.resolve(domain) })
- const { call } = setup(ctx)
+ const { call } = await setup(ctx)
expect(await call('store.keys', {})).toEqual([])
await call('store.set', { key: 'ok', value: 1 })
expect(await call('store.keys', {})).toEqual(['ok'])
@@ -247,7 +253,7 @@ describe('registrations outside a session', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(CommandRuntime)
- const { call, registrations, engine } = setup(ctx)
+ const { call, registrations, engine } = await setup(ctx)
await call('command.register', { name: 'Global', description: 'global command' })
await call('tool.register', { name: 'global_tool', description: 'global tool' })
expect(ctx.tools.schemas().map(tool => tool.name)).toContain('mcp__unit-mod__global_tool')
@@ -264,7 +270,7 @@ describe('registrations outside a session', () => {
describe('ui.log sinks', () => {
it('writes transcript lines at info and debug lines at debug', async () => {
- const { call, ctx } = setup()
+ const { call, ctx } = await setup()
const info = vi.fn()
const debug = vi.fn()
ctx.logger.info = info as never
@@ -293,7 +299,7 @@ describe('the committed directory owner', () => {
await mountAgentLoopTestDependencies(ctx, { workingDirectory: true })
const harness = await mountAgentLoopTestHarness(ctx)
await ctx.plugin(LocalSubprocessRuntime)
- const { call } = setup(ctx)
+ const { call } = await setup(ctx)
const agent = await harness.create(SessionId('directory-owner'), {}, { cwd: origin })
await ctx.workingDirectory.set(agent, current)
@@ -322,7 +328,7 @@ describe('the committed directory owner', () => {
})
it('fails explicitly when a session-bound operation has no directory owner', async () => {
- const { ctx, call } = setup()
+ const { ctx, call } = await setup()
await ctx.plugin(LocalFileSystem)
await ctx.plugin(LocalSubprocessRuntime)
const agent = fakeAgent(ctx)
diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts
index 534d6d3408..5ed24ad300 100644
--- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts
+++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
-import SessionController from '@deepseek-ai/dsh-api-session-controller'
+import { createSessionTestController } from '../../../api/session-controller/tests/test-remote.ts'
import Translator from '@deepseek-ai/dsh-experimental-translator'
import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session'
import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -66,7 +66,9 @@ async function fixture(origin?: 'subagent') {
saveSelection: async () => {},
} as never)
ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never)
- const sessions = new SessionController(ctx, { nativeOpen: false })
+ const sessions = await createSessionTestController(ctx, {
+ nativeOpen: false, cwd: root, defaultModelSelection: () => ({ provider: 'unused', model: 'unused' }),
+ })
await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` }))
const ui = ctx.plugin(CotTranslationController, {})
await ui
diff --git a/packages/experimental/hooks-claude-code/src/index.ts b/packages/experimental/hooks-claude-code/src/index.ts
index afbc60de08..1c79429b80 100644
--- a/packages/experimental/hooks-claude-code/src/index.ts
+++ b/packages/experimental/hooks-claude-code/src/index.ts
@@ -8,12 +8,13 @@
* @module @deepseek-ai/dsh-hooks-claude-code
*/
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-working-directory'
import { readFileSync } from 'node:fs'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-projection'
+import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-llm' {
@@ -43,10 +44,11 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent'
import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts'
export const name = 'hooks-claude-code'
-// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest
-// are read opportunistically via ctx.get so a deployment can omit them.
+
export const inject = ['shell', 'sessionProjections', 'workingDirectory']
+// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest
+// are read opportunistically via ctx.get so a deployment can omit them.
/** Plugin config: where the CC hook config lives + substitution roots. */
export interface Config {
/**
@@ -99,7 +101,12 @@ function assertPositiveInteger(name: string, value: number): void {
}
}
-export function apply(ctx: Context, config: Config): void {
+/** Register the hooks-claude-code plugin after its projections are ready.
+ * @param ctx - plugin context.
+ * @param config - hook configuration path and execution limits.
+ * @returns when the plugin is ready.
+ */
+export async function apply(ctx: Context, config: Config): Promise {
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
@@ -121,6 +128,8 @@ export function apply(ctx: Context, config: Config): void {
return
}
+ const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+
// Emit-shaped points run detached, so track their chains; disposal aborts
// active hooks and drains continuations before resolving.
const detached = createDetachedRuns()
@@ -242,7 +251,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/pre-execute', async (exec, next): Promise => {
- const turn = lastTurn(ctx, exec.agent)
+ const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
@@ -251,7 +260,7 @@ export function apply(ctx: Context, config: Config): void {
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/post-execute', async (exec, result, next): Promise => {
- const turn = lastTurn(ctx, exec.agent)
+ const turn = lastTurn(exec.agent)
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
@@ -301,6 +310,12 @@ export function apply(ctx: Context, config: Config): void {
detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child, directory: 'committed' } : {}, signal: detached.signal })
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStop hook failed: ${String(error)}`) }))
})
+
+ function lastTurn(agent: Agent | undefined): number {
+ if (!agent) return 0
+ const boundary = turnBoundary.stateOf(agent.session)
+ return boundary.lastTurn
+ }
}
/**
@@ -314,13 +329,6 @@ const SUBAGENT_TYPE = 'general-purpose'
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
// hook input schema; this is the part a bridge owns. ---
-/** The last open turn number in the agent's log, or 0 without an agent. */
-function lastTurn(ctx: Context, agent: Agent | undefined): number {
- if (!agent) return 0
- const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection
- return boundary.lastTurn
-}
-
/** Flatten content blocks to the text a hook payload carries (the common case). */
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('')
diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts
index b77aa85c96..dec29257f9 100644
--- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts
+++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts
@@ -381,7 +381,7 @@ export function defineCoverageCases(group: CoverageGroup): void {
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
- HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
+ await HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts
index 139a99de6d..6159a90dda 100644
--- a/packages/experimental/hooks-codex/src/index.ts
+++ b/packages/experimental/hooks-codex/src/index.ts
@@ -11,12 +11,13 @@
// Each dialect bridge keeps its complete dependency list visible at the entry
// point; a cross-package facade for imports alone would add indirection.
/* jscpd:ignore-start */
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-working-directory'
import { readFileSync } from 'node:fs'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-projection'
+import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
declare module '@deepseek-ai/dsh-llm' {
@@ -41,11 +42,13 @@ import {
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
import { parseCodexConfig, type CodexHookConfig } from './config.ts'
-/* jscpd:ignore-end */
export const name = 'hooks-codex'
+
export const inject = ['shell', 'sessionProjections', 'workingDirectory']
+/* jscpd:ignore-end */
+
/** Plugin config: where the Codex hooks.json lives + the model name for payloads. */
export interface Config {
/**
@@ -84,7 +87,12 @@ function assertPositiveInteger(name: string, value: number): void {
}
}
-export function apply(ctx: Context, config: Config): void {
+/** Register the hooks-codex plugin after its projections are ready.
+ * @param ctx - plugin context.
+ * @param config - hook configuration path and execution limits.
+ * @returns when the plugin is ready.
+ */
+export async function apply(ctx: Context, config: Config): Promise {
// Validate before config parsing so a bad value cannot be hidden by its early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
@@ -102,6 +110,7 @@ export function apply(ctx: Context, config: Config): void {
return
}
+ const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
const model = config.model ?? ''
// SessionStart is the one emit-shaped (detached) point Codex has: track its
@@ -195,7 +204,6 @@ export function apply(ctx: Context, config: Config): void {
.catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })
detached.track(run)
await run
- /* jscpd:ignore-end */
})
// UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask.
@@ -226,18 +234,17 @@ export function apply(ctx: Context, config: Config): void {
// PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored).
ctx.on('tools/pre-execute', async (exec, next): Promise => {
- const turn = lastTurn(ctx, exec.agent)
- const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
- /* jscpd:ignore-end */
+ const turn = lastTurn(exec.agent)
+ const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
return next()
})
// PostToolUse → PostToolDecision (block with feedback, or attach context).
ctx.on('tools/post-execute', async (exec, result, next): Promise => {
- const turn = lastTurn(ctx, exec.agent)
+ const turn = lastTurn(exec.agent)
/* jscpd:ignore-start */
- const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
+ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} }
@@ -261,8 +268,7 @@ export function apply(ctx: Context, config: Config): void {
// avoid continuing the same turn indefinitely. It is always false here, so an
// unconditionally blocking hook force-continues every step until it self-limits.
ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => {
- const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
- /* jscpd:ignore-end */
+ const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation; a block with no reason (exit 2,
// empty stderr) still forces it — fall back to a generic steering line
@@ -271,6 +277,27 @@ export function apply(ctx: Context, config: Config): void {
agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE }))
}
})
+
+ function lastTurn(agent: Agent | undefined): number {
+ if (!agent) return 0
+ return turnBoundary.stateOf(agent.session).lastTurn
+ }
+
+ function turnBase(agent: Agent | undefined, event: string, model: string): Record {
+ return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) }
+ }
+
+ function preToolPayload(exec: ToolExecution, model: string): Record {
+ // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
+ // a hardcoded constant would disagree with what the matcher tests and make a
+ // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
+ // shape (its shell payload), derived from the call's `command` arg when present.
+ return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
+ }
+
+ function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record {
+ return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
+ }
}
// --- Codex DIALECT payloads: snake_case, model on every event, turn_id on
@@ -279,11 +306,6 @@ export function apply(ctx: Context, config: Config): void {
// These small payload helpers intentionally remain next to the dialect shape;
// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol.
/* jscpd:ignore-start */
-function lastTurn(ctx: Context, agent: Agent | undefined): number {
- if (!agent) return 0
- /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */
- return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0
-}
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('')
@@ -303,11 +325,6 @@ function base(agent: Agent | undefined, event: string, model: string): Record {
- return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) }
-}
-
/** Extract a `command` string from a tool call's parsed arguments, else ''. */
function commandOf(args: unknown): string {
if (typeof args === 'object' && args !== null && 'command' in args) {
@@ -316,15 +333,3 @@ function commandOf(args: unknown): string {
}
return ''
}
-
-function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record {
- // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject);
- // a hardcoded constant would disagree with what the matcher tests and make a
- // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }`
- // shape (its shell payload), derived from the call's `command` arg when present.
- return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId }
-}
-
-function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record {
- return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
-}
diff --git a/packages/experimental/hooks-codex/tests/coverage-cases.ts b/packages/experimental/hooks-codex/tests/coverage-cases.ts
index 499a4a8fc1..d05a64984e 100644
--- a/packages/experimental/hooks-codex/tests/coverage-cases.ts
+++ b/packages/experimental/hooks-codex/tests/coverage-cases.ts
@@ -326,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
ctx.logger.warn = warn as never
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
- HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
+ await HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent)
diff --git a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts
index b2bf78ddca..282662b1a0 100644
--- a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts
+++ b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts
@@ -92,7 +92,7 @@ async function harness(
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter(script, reasoningEfforts)
ctx.llm.registerAdapter(['title-route'], adapter)
@@ -215,7 +215,7 @@ describe('all-messages LLM title provider', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
// A one-byte fallback cap cannot derive a title from the multibyte message,
// so the service supplies no currentTitle to the provider.
await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 })
diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts
index 61e18cc8f5..8bbf6c3bff 100644
--- a/packages/experimental/tool-session-query/src/index.ts
+++ b/packages/experimental/tool-session-query/src/index.ts
@@ -9,13 +9,11 @@ import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { toolInput } from './input.ts'
-import { operations } from './operations.ts'
+import { SessionQueryOperations } from './operations.ts'
import { presentation } from './presentation.ts'
-/** Cordis plugin name used by Loader diagnostics. */
export const name = 'tool-session-query'
-/** Capability services required by the model-facing consumer. */
export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections']
/** Default maximum number of authorized search hits returned by one call. */
@@ -53,8 +51,15 @@ const PROMPT_TEXT =
+ 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with '
+ 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.'
-/** Register all five tools and their shared model guidance. */
-export function apply(ctx: Context, config: Config): void {
+/**
+ * Register all five tools and their shared model guidance.
+ * @param ctx - owning plugin context.
+ * @param config - search limits.
+ * @returns when the tools and their projection reader are ready.
+ */
+export async function apply(ctx: Context, config: Config): Promise {
+ const operations = new SessionQueryOperations(ctx)
+ await operations.initialize()
const resolved = resolveConfig(config)
ctx.systemPrompt.section({
name: 'tool:session-query',
@@ -68,7 +73,7 @@ export function apply(ctx: Context, config: Config): void {
parameters: toolInput.sessionSearchParameters,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
- execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults),
+ execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults),
presentCall: presentation.presentSessionSearchCall,
}))
@@ -78,7 +83,7 @@ export function apply(ctx: Context, config: Config): void {
parameters: toolInput.eventSearchParameters,
output: TEXT_OUTPUT,
timeoutMs: resolved.searchTimeoutMs,
- execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults),
+ execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults),
presentCall: presentation.presentEventSearchCall,
}))
@@ -88,7 +93,7 @@ export function apply(ctx: Context, config: Config): void {
parameters: toolInput.targetSessionParameter,
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
- execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec),
+ execute: (args, exec) => operations.executeSessionTrace(args, exec),
presentCall: presentation.presentSessionTraceCall,
}))
@@ -101,7 +106,7 @@ export function apply(ctx: Context, config: Config): void {
},
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
- execute: (args, exec) => operations.executeEventTrace(ctx, args, exec),
+ execute: (args, exec) => operations.executeEventTrace(args, exec),
presentCall: args => presentation.presentEventTargetCall('Trace event', args),
}))
@@ -116,7 +121,7 @@ export function apply(ctx: Context, config: Config): void {
},
output: TEXT_OUTPUT,
isConcurrencySafe: () => true,
- execute: (args, exec) => operations.executeEventRead(ctx, args, exec),
+ execute: (args, exec) => operations.executeEventRead(args, exec),
presentCall: args => presentation.presentEventTargetCall('Read event', args),
}))
}
diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts
index 3d5fa17f31..4c05480221 100644
--- a/packages/experimental/tool-session-query/src/operations.ts
+++ b/packages/experimental/tool-session-query/src/operations.ts
@@ -4,6 +4,8 @@
* @module @deepseek-ai/dsh-tool-session-query/operations
*/
+import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { Context } from '@deepseek-ai/cordis'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import { SessionSeq } from '@deepseek-ai/dsh-session'
@@ -52,193 +54,249 @@ interface SearchCollection {
readonly capped: boolean
}
-async function executeSessionSearch(
- ctx: Context,
- args: SessionSearchArgs,
- exec: ToolRunContext,
- maxResults: number,
-): Promise {
- const caller = workspaceAccess.callerOf(exec, ctx)
- const cwd = caller.header.cwd
- if (cwd === undefined) {
- throw new HarnessError(
- 'cross-session search is unavailable because the caller session has no workspace',
- 'SESSION_QUERY_TOOL_UNAUTHORIZED',
- )
- }
- const query = toolInput.normalizeQuery(args.query)
- const sessionFilters = toolInput.buildSessionFilters(args)
- const eventFilters = toolInput.buildEventFilters({
- seqFrom: args.event_seq_from,
- seqTo: args.event_seq_to,
- timeFrom: args.event_time_from,
- timeTo: args.event_time_to,
- eventTypes: args.event_types,
- surfaces: args.event_surfaces,
- })
- const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids)
- if (requestedParentIds !== undefined || args.include_root_sessions === true) {
- const authorizedParentIds = requestedParentIds === undefined
- ? new Set()
- : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
- const parentValues: Array = requestedParentIds
- ?.filter(id => authorizedParentIds.has(id)) ?? []
- if (args.include_root_sessions === true) parentValues.push(null)
- if (parentValues.length === 0) return presentation.formatEmptySessionSearch()
- sessionFilters.push({ kind: 'parent', values: parentValues })
- }
- sessionFilters.push({ kind: 'cwd', values: [cwd] })
- const collected = await collectPages(
- maxResults,
- exec.signal,
- cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () =>
- ctx.sessionQuery.searchSessions({
- query,
- sessionFilters,
- eventFilters,
- ...cursor === undefined ? {} : { cursor },
- }, { signal: exec.signal })),
- hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller),
- )
+/** Session-history tool operations over the caller’s current turn state. */
+export class SessionQueryOperations {
+ private turnBoundary!: ProjectionHandle
- const parentIds = collected.items
- .map(hit => hit.header.parentSession)
- .filter((id): id is SessionId => id !== undefined)
- const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal)
- const titles = await workspaceAccess.readTitles(
- ctx,
- caller,
- collected.items.map(hit => hit.header.id),
- exec.signal,
- )
- return presentation.formatSessionSearch(collected, titles, authorizedParents)
-}
+ constructor(private readonly ctx: Context) {}
-async function executeEventSearch(
- ctx: Context,
- args: EventSearchArgs,
- exec: ToolRunContext,
- maxResults: number,
-): Promise {
- const caller = workspaceAccess.callerOf(exec, ctx)
- const sessionId = workspaceAccess.targetId(args, caller)
- await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
- const query = toolInput.normalizeQuery(args.query)
- const range = toolInput.sequenceRange(args.seq_from, args.seq_to)
- if (sessionId === caller.id) {
- const stepStartSeq = caller.boundary?.lastStepStartSeq
- if (stepStartSeq === undefined) {
+ /**
+ * Acquire projection readers before exposing dependent operations.
+ * @returns when all required registrations are ready.
+ */
+ async initialize(): Promise {
+ this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ }
+
+ private callerOf(exec: ToolRunContext) {
+ const agent = exec.agent
+ if (agent === undefined) {
throw new HarnessError(
- 'current-session search requires an active step boundary',
- 'SESSION_QUERY_TOOL_NO_CURRENT_STEP',
+ 'session query tools require an agent-bound caller',
+ 'SESSION_QUERY_TOOL_MISSING_AGENT',
)
}
- // `null` (no step started yet) caps the range to a degenerate `to` the
- // filter validation rejects — the loop never runs tools outside a step.
- range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1)
- }
- const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
- if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
- return presentation.formatEventSearch(sessionId, title, { items: [], capped: false })
- }
- const filters = toolInput.buildEventFilters({
- seqFrom: range.from,
- seqTo: range.to,
- timeFrom: args.time_from,
- timeTo: args.time_to,
- eventTypes: args.event_types,
- surfaces: args.surfaces,
- })
- const collected = await collectPages(
- maxResults,
- exec.signal,
- async (cursor): Promise => {
- const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () =>
- ctx.sessionQuery.searchEvents({
- sessionId,
- query,
- filters,
- ...cursor === undefined ? {} : { cursor },
- }, { signal: exec.signal }))
- workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session)
- return page
- },
- () => true,
- )
- return presentation.formatEventSearch(sessionId, title, collected)
-}
-
-async function executeSessionTrace(
- ctx: Context,
- args: SessionTargetArgs,
- exec: ToolRunContext,
-): Promise {
- const caller = workspaceAccess.callerOf(exec, ctx)
- const sessionId = workspaceAccess.targetId(args, caller)
- await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
- const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () =>
- ctx.sessionQuery.traceSession(sessionId, exec.signal))
- workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header)
-
- const ancestors: SessionRecord[] = []
- let ancestorBoundary = false
- for (const ancestor of trace.ancestors) {
- if (!workspaceAccess.recordAuthorized(ancestor, caller)) {
- ancestorBoundary = true
- break
+ return {
+ id: agent.session.id,
+ header: agent.session.header,
+ boundary: this.turnBoundary.stateOf(agent.session),
}
- ancestors.push(ancestor)
}
- if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true
- const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller)
- const visibleIds = [
- trace.target.header.id,
- ...ancestors.map(record => record.header.id),
- ...workspaceAccess.descendantIds(descendants),
- ]
- const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal)
- return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles)
-}
-async function executeEventTrace(
- ctx: Context,
- args: EventTargetArgs,
- exec: ToolRunContext,
-): Promise {
- toolInput.assertNonNegativeSafeInteger('seq', args.seq)
- const seq = SessionSeq(args.seq)
- const caller = workspaceAccess.callerOf(exec, ctx)
- const sessionId = workspaceAccess.targetId(args, caller)
- await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
- const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () =>
- ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal))
- workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session)
- const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
- return presentation.formatEventTrace(sessionId, title, trace)
-}
+ /**
+ * Search other Sessions within the caller’s authorized workspace.
+ * @param args - Session and event filters.
+ * @param exec - Agent-bound caller and cancellation signal.
+ * @param maxResults - Maximum number of authorized matches to return.
+ * @returns Formatted authorized Session matches and truncation status.
+ */
+ async executeSessionSearch(
+ args: SessionSearchArgs,
+ exec: ToolRunContext,
+ maxResults: number,
+ ): Promise {
+ const ctx = this.ctx
+ const caller = this.callerOf(exec)
+ const cwd = caller.header.cwd
+ if (cwd === undefined) {
+ throw new HarnessError(
+ 'cross-session search is unavailable because the caller session has no workspace',
+ 'SESSION_QUERY_TOOL_UNAUTHORIZED',
+ )
+ }
+ const query = toolInput.normalizeQuery(args.query)
+ const sessionFilters = toolInput.buildSessionFilters(args)
+ const eventFilters = toolInput.buildEventFilters({
+ seqFrom: args.event_seq_from,
+ seqTo: args.event_seq_to,
+ timeFrom: args.event_time_from,
+ timeTo: args.event_time_to,
+ eventTypes: args.event_types,
+ surfaces: args.event_surfaces,
+ })
+ const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids)
+ if (requestedParentIds !== undefined || args.include_root_sessions === true) {
+ const authorizedParentIds = requestedParentIds === undefined
+ ? new Set()
+ : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal)
+ const parentValues: Array = requestedParentIds
+ ?.filter(id => authorizedParentIds.has(id)) ?? []
+ if (args.include_root_sessions === true) parentValues.push(null)
+ if (parentValues.length === 0) return presentation.formatEmptySessionSearch()
+ sessionFilters.push({ kind: 'parent', values: parentValues })
+ }
+ sessionFilters.push({ kind: 'cwd', values: [cwd] })
+ const collected = await collectPages(
+ maxResults,
+ exec.signal,
+ cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () =>
+ ctx.sessionQuery.searchSessions({
+ query,
+ sessionFilters,
+ eventFilters,
+ ...cursor === undefined ? {} : { cursor },
+ }, { signal: exec.signal })),
+ hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller),
+ )
-async function executeEventRead(
- ctx: Context,
- args: EventReadArgs,
- exec: ToolRunContext,
-): Promise {
- toolInput.assertNonNegativeSafeInteger('seq', args.seq)
- const seq = SessionSeq(args.seq)
- if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before)
- if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after)
- const caller = workspaceAccess.callerOf(exec, ctx)
- const sessionId = workspaceAccess.targetId(args, caller)
- await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
- const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () =>
- ctx.sessionQuery.readEvent({
- sessionId,
- seq,
- ...args.before === undefined ? {} : { before: args.before },
- ...args.after === undefined ? {} : { after: args.after },
- }, exec.signal))
- workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session)
- const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
- return presentation.formatEventRead(sessionId, title, window)
+ const parentIds = collected.items
+ .map(hit => hit.header.parentSession)
+ .filter((id): id is SessionId => id !== undefined)
+ const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal)
+ const titles = await workspaceAccess.readTitles(
+ ctx,
+ caller,
+ collected.items.map(hit => hit.header.id),
+ exec.signal,
+ )
+ return presentation.formatSessionSearch(collected, titles, authorizedParents)
+ }
+
+ /**
+ * Search authorized Session events, excluding the caller’s current step.
+ * @param args - Target Session and event filters.
+ * @param exec - Agent-bound caller and cancellation signal.
+ * @param maxResults - Maximum number of authorized matches to return.
+ * @returns Formatted matching events and truncation status.
+ */
+ async executeEventSearch(
+ args: EventSearchArgs,
+ exec: ToolRunContext,
+ maxResults: number,
+ ): Promise {
+ const ctx = this.ctx
+ const caller = this.callerOf(exec)
+ const sessionId = workspaceAccess.targetId(args, caller)
+ await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
+ const query = toolInput.normalizeQuery(args.query)
+ const range = toolInput.sequenceRange(args.seq_from, args.seq_to)
+ if (sessionId === caller.id) {
+ const stepStartSeq = caller.boundary.lastStepStartSeq
+ // `null` (no step started yet) caps the range to a degenerate `to` the
+ // filter validation rejects — the loop never runs tools outside a step.
+ range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1)
+ }
+ const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
+ if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
+ return presentation.formatEventSearch(sessionId, title, { items: [], capped: false })
+ }
+ const filters = toolInput.buildEventFilters({
+ seqFrom: range.from,
+ seqTo: range.to,
+ timeFrom: args.time_from,
+ timeTo: args.time_to,
+ eventTypes: args.event_types,
+ surfaces: args.surfaces,
+ })
+ const collected = await collectPages(
+ maxResults,
+ exec.signal,
+ async (cursor): Promise => {
+ const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () =>
+ ctx.sessionQuery.searchEvents({
+ sessionId,
+ query,
+ filters,
+ ...cursor === undefined ? {} : { cursor },
+ }, { signal: exec.signal }))
+ workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session)
+ return page
+ },
+ () => true,
+ )
+ return presentation.formatEventSearch(sessionId, title, collected)
+ }
+
+ /**
+ * Trace Session lineage within the caller’s workspace permissions.
+ * @param args - Optional target Session id; defaults to the caller.
+ * @param exec - Agent-bound caller and cancellation signal.
+ * @returns Formatted authorized ancestors and descendants.
+ */
+ async executeSessionTrace(
+ args: SessionTargetArgs,
+ exec: ToolRunContext,
+ ): Promise {
+ const ctx = this.ctx
+ const caller = this.callerOf(exec)
+ const sessionId = workspaceAccess.targetId(args, caller)
+ await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
+ const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () =>
+ ctx.sessionQuery.traceSession(sessionId, exec.signal))
+ workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header)
+
+ const ancestors: SessionRecord[] = []
+ let ancestorBoundary = false
+ for (const ancestor of trace.ancestors) {
+ if (!workspaceAccess.recordAuthorized(ancestor, caller)) {
+ ancestorBoundary = true
+ break
+ }
+ ancestors.push(ancestor)
+ }
+ if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true
+ const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller)
+ const visibleIds = [
+ trace.target.header.id,
+ ...ancestors.map(record => record.header.id),
+ ...workspaceAccess.descendantIds(descendants),
+ ]
+ const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal)
+ return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles)
+ }
+
+ /**
+ * Trace an event after authorizing its owning Session.
+ * @param args - Target Session and event sequence.
+ * @param exec - Agent-bound caller and cancellation signal.
+ * @returns Formatted event lineage.
+ */
+ async executeEventTrace(
+ args: EventTargetArgs,
+ exec: ToolRunContext,
+ ): Promise {
+ const ctx = this.ctx
+ toolInput.assertNonNegativeSafeInteger('seq', args.seq)
+ const seq = SessionSeq(args.seq)
+ const caller = this.callerOf(exec)
+ const sessionId = workspaceAccess.targetId(args, caller)
+ await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
+ const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () =>
+ ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal))
+ workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session)
+ const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
+ return presentation.formatEventTrace(sessionId, title, trace)
+ }
+
+ /**
+ * Read an event window after authorizing its owning Session.
+ * @param args - Target event and surrounding event counts.
+ * @param exec - Agent-bound caller and cancellation signal.
+ * @returns Formatted event window.
+ */
+ async executeEventRead(
+ args: EventReadArgs,
+ exec: ToolRunContext,
+ ): Promise {
+ const ctx = this.ctx
+ toolInput.assertNonNegativeSafeInteger('seq', args.seq)
+ const seq = SessionSeq(args.seq)
+ if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before)
+ if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after)
+ const caller = this.callerOf(exec)
+ const sessionId = workspaceAccess.targetId(args, caller)
+ await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal)
+ const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () =>
+ ctx.sessionQuery.readEvent({
+ sessionId,
+ seq,
+ ...args.before === undefined ? {} : { before: args.before },
+ ...args.after === undefined ? {} : { after: args.after },
+ }, exec.signal))
+ workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session)
+ const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal)
+ return presentation.formatEventRead(sessionId, title, window)
+ }
}
async function collectPages(
@@ -275,12 +333,3 @@ async function collectPages(
cursor = page.nextCursor
}
}
-
-/** Five model-facing session-query operation implementations. */
-export const operations = {
- executeSessionSearch,
- executeEventSearch,
- executeSessionTrace,
- executeEventTrace,
- executeEventRead,
-}
diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts
index 9eabee4e46..4f11e64f38 100644
--- a/packages/experimental/tool-session-query/src/workspace-access.ts
+++ b/packages/experimental/tool-session-query/src/workspace-access.ts
@@ -6,7 +6,6 @@
import type { Context } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
-import { HarnessError } from '@deepseek-ai/dsh-llm'
import {
type SessionHeader,
type SessionId as SessionIdValue,
@@ -16,15 +15,13 @@ import type {
SessionLineageNode,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
-import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-session-projection'
import { serviceBoundary } from './service-boundary.ts'
interface Caller {
readonly id: SessionIdValue
readonly header: SessionHeader
/** The caller's own-session boundary fold (the `turnBoundary` projection). */
- readonly boundary: TurnBoundaryProjection | undefined
+ readonly boundary: TurnBoundaryProjection
}
interface TitleView {
@@ -53,21 +50,6 @@ interface DescendantVisit {
readonly next: DescendantVisit | undefined
}
-function callerOf(exec: ToolRunContext, ctx: Context): Caller {
- const agent = exec.agent
- if (agent === undefined) {
- throw new HarnessError(
- 'session query tools require an agent-bound caller',
- 'SESSION_QUERY_TOOL_MISSING_AGENT',
- )
- }
- return {
- id: agent.session.id,
- header: agent.session.header,
- boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'),
- }
-}
-
function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue {
return args.session_id === undefined ? caller.id : brandString(args.session_id)
}
@@ -242,7 +224,6 @@ function titleText(view: TitleView): string {
/** Workspace-scoped caller authorization, title access, and lineage projection. */
export const workspaceAccess = {
- callerOf,
targetId,
authorizeTarget,
recordAuthorized,
diff --git a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts
index ed6402865f..d71079adb7 100644
--- a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts
+++ b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts
@@ -33,8 +33,8 @@ function fakeAgent(session: Session): Agent {
return { id: session.id, session } as unknown as Agent
}
-function registerTurnBoundary(ctx: Context): void {
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+async function registerTurnBoundary(ctx: Context): Promise {
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
}
describe('tool-session-query with the real SQLite provider', () => {
@@ -45,7 +45,7 @@ describe('tool-session-query with the real SQLite provider', () => {
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- registerTurnBoundary(ctx)
+ await registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
@@ -118,7 +118,7 @@ describe('tool-session-query with the real SQLite provider', () => {
contexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- registerTurnBoundary(ctx)
+ await registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts
index 3d11ad3f85..c5dd7fddfc 100644
--- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts
+++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts
@@ -31,6 +31,7 @@ import SessionQueryEngine, {
} from '@deepseek-ai/dsh-session-query'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import type { Config as ToolSessionQueryConfig } from '@deepseek-ai/dsh-tool-session-query'
import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query'
declare module '@deepseek-ai/dsh-llm' {
@@ -207,12 +208,12 @@ interface Mounted {
call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise
}
-function registerTurnBoundary(ctx: Context): void {
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+async function registerTurnBoundary(ctx: Context): Promise {
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
}
async function mount(
- config: ToolSessionQuery.Config = {},
+ config: ToolSessionQueryConfig = {},
callerCwd: string | null = '/work',
enforceTimeout = false,
): Promise {
@@ -220,7 +221,7 @@ async function mount(
activeContexts.push(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- registerTurnBoundary(ctx)
+ await registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
if (enforceTimeout) await ctx.plugin(TimeoutPolicy)
@@ -329,14 +330,14 @@ describe('registration and schemas', () => {
it('fails invalid direct config before registering anything', async () => {
const mounted = await mount()
for (const maxSearchResults of [0, 1.5, Number.NaN]) {
- expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) })
- .toThrow('maxSearchResults')
+ await expect(ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }))
+ .rejects.toThrow('maxSearchResults')
}
for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) {
- expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) })
- .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
+ await expect(ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }))
+ .rejects.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`)
}
- expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow()
+ await expect(ToolSessionQuery.apply(new Context(), {})).rejects.toThrow()
})
it('expresses the complete Node timer range in the Loader config schema', () => {
@@ -1652,7 +1653,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
expect(text(result)).toContain('Result cap reached')
})
- it('rejects current-session search without the turnBoundary fold', async () => {
+ it('rejects current-session search before any step has started', async () => {
const ctx = new Context()
activeContexts.push(ctx)
await ctx.plugin(SessionStore)
@@ -1669,7 +1670,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
signal: new AbortController().signal,
agent: fakeAgent(session),
})
- expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP')
+ expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined()
+ expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER')
})
it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => {
diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts
index f495097df2..6d8c8e9698 100644
--- a/packages/fs/tool-fs/tests/tools.spec.ts
+++ b/packages/fs/tool-fs/tests/tools.spec.ts
@@ -841,7 +841,7 @@ describe('sandbox escalation API (write/edit)', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' })
await ctx.plugin(SandboxingFakeFs)
await ctx.plugin(FsPolicy)
diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts
index 48a02e485c..cbc0344b95 100644
--- a/packages/goal/goal/src/index.ts
+++ b/packages/goal/goal/src/index.ts
@@ -5,7 +5,7 @@
*/
import { randomUUID } from 'node:crypto'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
import type { ZodType } from 'zod'
@@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import {
applyGoalEvent,
@@ -247,15 +247,21 @@ export class GoalService extends TypertRemoteService {
private readonly resolved: ResolvedConfig
private readonly runtimeStates = new WeakMap()
+ private projection!: ProjectionHandle
+
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'goals')
this.resolved = {
defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256),
}
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ this.projection = await ctx.sessionProjections.register(goalProjectionDefinition)
ctx.on('agent/created', ({ agent }) => {
this.setActivation(agent.session, 'disarmed')
})
- ctx.sessionProjections.register(goalProjectionDefinition)
ctx.on('session/event', (session, event) => {
if (event.type !== 'goal/change') return
const runtime = this.runtimeState(session)
@@ -474,8 +480,7 @@ export class GoalService extends TypertRemoteService {
/** Read the current durable projection maintained by the registry. */
private state(session: Session): GoalProjection | null {
- const state = this.ctx.sessionProjections.stateOf(session, 'goal')
- if (state === undefined) throw new Error('goal projection is not registered')
+ const state = this.projection.stateOf(session)
if (state.failure !== null) throw new Error(state.failure)
return state.current
}
@@ -497,9 +502,7 @@ export class GoalService extends TypertRemoteService {
const runtime = this.runtimeState(session)
if (runtime.activation === activation) return
runtime.activation = activation
- const state = this.ctx.sessionProjections.stateOf(session, 'goal')
- /* v8 ignore next -- static inject requires the projection registry before this service activates. */
- if (state === undefined) return
+ const state = this.projection.stateOf(session)
if (state.failure !== null) return
const goal = this.view(state.current, runtime)
this.ctx.emit('goal/activation-changed', {
diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts
index fe9c2d0030..462a0f59fa 100644
--- a/packages/goal/goal/tests/goal.spec.ts
+++ b/packages/goal/goal/tests/goal.spec.ts
@@ -1,3 +1,4 @@
+import { Service } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
@@ -174,8 +175,11 @@ describe('GoalService creation and replay', () => {
await ctx.plugin(SessionProjectionRegistry)
const stub = stubAgent('goal-direct-construction')
await ctx.agents.register(stub.agent)
- const goals = new GoalService(ctx)
- await new Promise(resolve => setImmediate(resolve))
+ class DirectGoalService extends GoalService {
+ initialize(): Promise { return this[Service.init]() }
+ }
+ const goals = new DirectGoalService(ctx)
+ await goals.initialize()
expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({
objective: 'direct', maxGoalRounds: 256,
})
@@ -277,8 +281,8 @@ describe('GoalService creation and replay', () => {
await fiber.dispose()
expect(ctx.get('goals')).toBeUndefined()
- expect(ctx.sessionProjections.stateOf(stub.session, 'goal')).toBeUndefined()
- expect(() => first.get(stub.agent)).toThrow('goal projection is not registered')
+ expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined()
+ expect(first.get(stub.agent)).toMatchObject({ id: goal.id })
await ctx.plugin(GoalService)
expect(ctx.goals).not.toBe(first)
diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts
index 227da38ad8..64071e4038 100644
--- a/packages/goal/goal/tests/projection.spec.ts
+++ b/packages/goal/goal/tests/projection.spec.ts
@@ -235,9 +235,10 @@ describe('goal projection unit', () => {
const bench = await harness(true)
bench.ctx.goals.create(bench.agent, { objective: 'poisoned replay' })
const failure = 'goal replay failed at session event 0: invalid restored goal stream'
- const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal')
+ const handle = await bench.ctx.sessionProjections.register(goalProjectionDefinition)
+ const state = handle.stateOf(bench.session)
expect(state).toBeDefined()
- Object.assign(state!, { failure })
+ Object.assign(state, { failure })
await expect(agentEvents(bench.ctx, bench.agent).serial('agent/created', { source: 'resume' }))
.resolves.toBeUndefined()
diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml
index 492ae8374e..1d97f69a1b 100644
--- a/packages/goal/tool-goal/README.i18n.yaml
+++ b/packages/goal/tool-goal/README.i18n.yaml
@@ -30,8 +30,8 @@
en: 4e9fdb5a89f4baa3
zh: 50d182467ae1f3a7
/deepseek-ai-dsh-tool-goal/understand-the-implementation/design:
- en: a30f36299c8107f7
- zh: 65e6e1e19aeb5281
+ en: 905e5e105efdf7a8
+ zh: 3704176a160aabb1
/deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map:
en: 4d5c648787409868
zh: b4938bfe660f18aa
diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md
index 158446e156..9ab458268d 100644
--- a/packages/goal/tool-goal/README.md
+++ b/packages/goal/tool-goal/README.md
@@ -69,6 +69,7 @@ This section explains how the tools enforce authority and render output; the obs
### Design
- **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does.
+- **Current-turn input projection.** A host-only projection retains human-input presence and admitted goal-round identifiers, resets at each turn boundary, and reconstructs these facts from committed events when registered.
- **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority.
- **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution.
- **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context.
diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md
index 1ec0a8e1f8..9e868061c2 100644
--- a/packages/goal/tool-goal/README.zh.md
+++ b/packages/goal/tool-goal/README.zh.md
@@ -69,6 +69,7 @@ kind: "package-reference"
### 设计
- **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。
+- **当前轮输入投影。** 仅供 Host 使用的投影保存是否有人类输入及已接纳的目标轮次标识,在每个轮次边界重置,并在注册时从已提交事件重建这些事实。
- **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。
- **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。
- **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。
diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json
index e5c0e9d8e7..65a9e48f4b 100644
--- a/packages/goal/tool-goal/package.json
+++ b/packages/goal/tool-goal/package.json
@@ -39,7 +39,8 @@
"@deepseek-ai/dsh-session-projection": "workspace:*"
},
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:~"
+ "@deepseek-ai/schemastery": "workspace:~",
+ "zod": "4.4.3"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:~",
diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts
index eadc0bd8f9..6214005230 100644
--- a/packages/goal/tool-goal/src/authority.ts
+++ b/packages/goal/tool-goal/src/authority.ts
@@ -2,17 +2,57 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
-import type { GoalView } from '@deepseek-ai/dsh-goal'
+import { GoalId, type GoalView } from '@deepseek-ai/dsh-goal'
import { HarnessError } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session'
import type { ToolRunContext } from '@deepseek-ai/dsh-tools'
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
+import { z as zod } from 'zod'
-/** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */
+const turnAuthoritySchema = zod.object({
+ open: zod.boolean(),
+ directHuman: zod.boolean(),
+ goalRounds: zod.array(zod.object({
+ goalId: zod.string().transform(GoalId),
+ revision: zod.number().int().positive(),
+ round: zod.number().int().positive(),
+ })),
+})
+
+/** Accepted input authority for the currently open turn, reset at both turn boundaries. */
+type TurnAuthority = zod.infer
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Input sources admitted in the current turn for goal-tool authorization. */
+ goalToolAuthority: TurnAuthority
+ }
+}
+
+const turnAuthorityProjection = {
+ key: 'goalToolAuthority',
+ stateVersion: 1,
+ stateSchema: turnAuthoritySchema,
+ init: (): TurnAuthority => ({ open: false, directHuman: false, goalRounds: [] }),
+ apply: (state, event) => {
+ if (event.type === 'turn/start' || event.type === 'turn/end') {
+ return { open: event.type === 'turn/start', directHuman: false, goalRounds: [] }
+ }
+ if (!state.open || event.type !== 'user/message') return state
+ const source = event.data.source
+ if (source.kind === 'user') return state.directHuman ? state : { ...state, directHuman: true }
+ if (source.kind === 'goal') {
+ const { goalId, revision, round } = source
+ return { ...state, goalRounds: [...state.goalRounds, { goalId, revision, round }] }
+ }
+ // Other message producers do not grant goal-tool authority.
+ return state
+ },
+} satisfies ProjectionDefinition<'goalToolAuthority', TurnAuthority>
+
+/** The authenticated caller and immutable authority facts captured for one tool call. */
export interface GoalToolExecution {
readonly agent: Agent
- readonly events: readonly SessionEvent[]
- readonly openTurnStartSeq: SessionSeq
+ readonly authority: TurnAuthority
}
/** Hard authority granted to one state-changing call. */
@@ -25,51 +65,44 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never {
throw new HarnessError(message, code)
}
-/** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */
-function openTurnEvents(
- ctx: Context,
- agent: Agent,
-): Pick {
- // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
- const events = agent.session.snapshotEvents()
- const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')
- if (boundary === undefined || boundary.openTurnStartSeq === null) {
- reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
- }
- return { events, openTurnStartSeq: boundary.openTurnStartSeq }
-}
+/** Resolves goal-tool authority from the live driver and its committed turn. */
+export class GoalToolExecutionResolver {
+ private authority!: ProjectionHandle
-/**
- * Resolve and authenticate the calling agent and its driver boundary.
- * @param ctx - Context carrying the live agent registry.
- * @param exec - Tool execution metadata supplied by the registry.
- * @returns The authenticated agent, immutable event cut, and open-turn boundary.
- */
-export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution {
- const agent = exec.agent
- if (agent === undefined) {
- return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED')
- }
- if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running'
- || ctx.agents.currentInitiator() !== agent) {
- return reject(
- 'goal tools require the exact live calling agent inside its active driver',
- 'GOAL_TOOL_DRIVER_REQUIRED',
- )
- }
- return { agent, ...openTurnEvents(ctx, agent) }
-}
+ /** @param ctx - Context carrying the live agent registry and session projections. */
+ constructor(private readonly ctx: Context) {}
-/** Whether the captured open turn contains an event accepted by `predicate`. */
-function someOpenTurnEvent(
- execution: GoalToolExecution,
- predicate: (event: SessionEvent) => boolean,
-): boolean {
- for (let seq = execution.openTurnStartSeq + 1; seq < execution.events.length; seq += 1) {
- const event = execution.events[seq]
- if (event !== undefined && predicate(event)) return true
+ /**
+ * Acquire projection readers before exposing dependent operations.
+ * @returns when all required registrations are ready.
+ */
+ async initialize(): Promise {
+ this.authority = await this.ctx.sessionProjections.register(turnAuthorityProjection)
+ }
+
+ /**
+ * Authenticate the caller and capture its current turn's accepted input sources.
+ * @param exec - Tool execution carrying the live calling agent.
+ * @returns the caller and projected authority facts.
+ */
+ resolve(exec: ToolRunContext): GoalToolExecution {
+ const agent = exec.agent
+ if (agent === undefined) {
+ return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED')
+ }
+ if (this.ctx.agents.get(agent.id) !== agent || agent.status !== 'running'
+ || this.ctx.agents.currentInitiator() !== agent) {
+ return reject(
+ 'goal tools require the exact live calling agent inside its active driver',
+ 'GOAL_TOOL_DRIVER_REQUIRED',
+ )
+ }
+ const authority = this.authority.stateOf(agent.session)
+ if (!authority.open) {
+ reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED')
+ }
+ return { agent, authority }
}
- return false
}
/**
@@ -79,17 +112,14 @@ function someOpenTurnEvent(
*/
function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean {
if (!ctx.agents.roots().includes(execution.agent)) return false
- return someOpenTurnEvent(execution, event =>
- event.type === 'user/message' && event.data.source.kind === 'user')
+ return execution.authority.directHuman
}
/** Whether this turn is the current goal's exact admitted round. */
function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean {
- return someOpenTurnEvent(execution, event => event.type === 'user/message'
- && event.data.source.kind === 'goal'
- && event.data.source.goalId === goal.id
- && event.data.source.revision === goal.revision
- && event.data.source.round === goal.roundsStarted)
+ return execution.authority.goalRounds.some(round => round.goalId === goal.id
+ && round.revision === goal.revision
+ && round.round === goal.roundsStarted)
}
/**
diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts
index 9f47f1a77d..a364dec967 100644
--- a/packages/goal/tool-goal/src/index.ts
+++ b/packages/goal/tool-goal/src/index.ts
@@ -20,12 +20,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import {
completionAuthority,
- goalToolExecution,
+ GoalToolExecutionResolver,
requireDirectHuman,
} from './authority.ts'
import { renderWrapupContext } from './wrapup.ts'
export const name = 'tool-goal'
+
export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections']
/** Model policy and hard lower bounds for goal-state updates. */
@@ -184,7 +185,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen
}
/** Register the three Codex-shaped goal tools and their shared policy section. */
-export function apply(ctx: Context, config: Config): void {
+export async function apply(ctx: Context, config: Config): Promise {
+ const executionResolver = new GoalToolExecutionResolver(ctx)
+ await executionResolver.initialize()
const resolved = resolveConfig(config)
ctx.systemPrompt.section({
name: 'tool:goal',
@@ -198,7 +201,7 @@ export function apply(ctx: Context, config: Config): void {
parameters: {},
output: GOAL_OUTPUT,
execute(_args, exec) {
- const execution = goalToolExecution(ctx, exec)
+ const execution = executionResolver.resolve(exec)
return Promise.resolve(goalValue(ctx.goals.get(execution.agent)))
},
presentCall: () => present('Read current goal', 'read'),
@@ -220,7 +223,7 @@ export function apply(ctx: Context, config: Config): void {
},
output: GOAL_OUTPUT,
execute(args, exec) {
- const execution = goalToolExecution(ctx, exec)
+ const execution = executionResolver.resolve(exec)
requireDirectHuman(ctx, execution)
const goal = ctx.goals.create(execution.agent, {
objective: args.objective,
@@ -253,7 +256,7 @@ export function apply(ctx: Context, config: Config): void {
},
output: GOAL_OUTPUT,
execute(args, exec) {
- const execution = goalToolExecution(ctx, exec)
+ const execution = executionResolver.resolve(exec)
const ref = goalRef(args.goal_id, args.revision)
const replacements = {
...hasText(args.objective) ? { objective: args.objective } : {},
diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts
index ecc7822bce..0bb6a91a48 100644
--- a/packages/goal/tool-goal/tests/tool-goal.spec.ts
+++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts
@@ -3,7 +3,6 @@ import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent'
-import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import GoalService, { GoalId } from '@deepseek-ai/dsh-goal'
import type { GoalRef } from '@deepseek-ai/dsh-goal'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
@@ -19,6 +18,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
+import * as GoalTools from '@deepseek-ai/dsh-tool-goal'
import * as toolGoal from '@deepseek-ai/dsh-tool-goal'
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -104,9 +104,8 @@ async function harness(config: toolGoal.Config = {}) {
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ToolRuntime)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(GoalService)
- const fiber = await ctx.plugin(toolGoal, config)
+ const fiber = await ctx.plugin(GoalTools, config)
const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx)
await ctx.agents.register(root.agent)
return { ctx, fiber, root }
@@ -202,25 +201,27 @@ describe('goal tool registration and presentation', () => {
it('fails invalid direct config before registering anything', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(GoalService)
- expect(() => {
- toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })
- }).toThrow(
+ await expect(GoalTools.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow(
'blockedAfterConsecutiveRounds must be a positive safe integer',
)
expect(ctx.tools.get('get_goal')).toBeUndefined()
})
- it('resolves the direct-apply default before registration', async () => {
+ it('resolves the direct-constructor default before registration', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ToolRuntime)
await ctx.plugin(GoalService)
- toolGoal.apply(ctx, {})
+ await GoalTools.apply(ctx, {})
const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal')
expect(section?.text).toContain('at least 3 consecutive goal rounds')
})
@@ -331,7 +332,27 @@ describe('goal tool execution authority', () => {
expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
})
- it('accepts direct human steering in a goal-sourced root turn', async () => {
+ it('does not carry human authority into a later non-human turn', async () => {
+ const { ctx, root } = await harness()
+ const turn = openTurn(root, { kind: 'user' })
+ await execute(ctx, 'get_goal', {}, root.agent)
+ closeTurn(root, turn)
+ openTurn(root, { kind: 'test' })
+ const result = await execute(ctx, 'create_goal', { objective: 'unauthorized' }, root.agent)
+ expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED')
+ expect(ctx.goals.get(root.agent)).toBeUndefined()
+ })
+
+ it('reconstructs current-turn authority when the tools mount after input was committed', async () => {
+ const { ctx, root, fiber } = await harness()
+ await fiber.dispose()
+ openTurn(root, { kind: 'user' })
+ await ctx.plugin(GoalTools, {})
+ const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent)
+ expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' })
+ })
+
+ it.each([1, 2])('accepts %i direct human steering messages in a goal-sourced root turn', async (messageCount) => {
const { ctx, root } = await harness()
const humanTurn = openTurn(root, { kind: 'user' })
const created = ctx.goals.create(root.agent, { objective: 'steer me' })
@@ -339,10 +360,12 @@ describe('goal tool execution authority', () => {
openTurn(root, {
kind: 'goal', goalId: created.id, revision: created.revision, round: 1,
})
- root.session.append('user/message', createUserMessage({
- content: [{ type: 'text', text: 'pause now' }],
- source: { kind: 'user' },
- }), { surfaceOp: 'append' })
+ for (let index = 0; index < messageCount; index++) {
+ root.session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: 'pause now' }],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ }
const paused = await execute(ctx, 'update_goal', {
goal_id: created.id, revision: created.revision, action: 'pause',
}, root.agent)
diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml
index 949ada7106..46968ede01 100644
--- a/packages/interaction/permission-presets/README.i18n.yaml
+++ b/packages/interaction/permission-presets/README.i18n.yaml
@@ -39,14 +39,14 @@
en: b01849a5fa73aae9
zh: cbd7fcf50117502b
/deepseek-ai-dsh-permission-presets/understand-the-implementation/read-side-and-custom:
- en: c9ca792038f44654
- zh: 83cc2641c800c114
+ en: 76deeb5fbcdf7525
+ zh: e96ad7e107947c23
/deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse:
en: 893cea88bce22903
zh: 2431e19ced670511
/deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command:
- en: 2e79bec74274b054
- zh: 256526ab1b4b55e9
+ en: 341f80202edd66a3
+ zh: bd8ebb9d011b547f
/deepseek-ai-dsh-permission-presets/further-exploration:
en: ca27c3e438cffdc5
zh: ae5d25ae55e5d503
diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md
index e21a41a053..d1179a9926 100644
--- a/packages/interaction/permission-presets/README.md
+++ b/packages/interaction/permission-presets/README.md
@@ -86,7 +86,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi
### Read side and `custom`
-`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly.
+`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. The service acquires its state reader during activation.
`optionOf(name)` returns a configured entry, the live Auto entry, or the display-only `custom` entry. It throws only when the name matches none of those; a withdrawn Auto entry is unavailable.
@@ -96,7 +96,7 @@ Mounting pins every live and future session: a genuinely fresh session gains the
### Catalog, projection, and optional command
-The service requires `ctx.sessionProjections` and registers a `permissions` projection containing only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection fail explicitly when the projection key is absent.
+The service requires `ctx.sessionProjections` and awaits registration of `permissions`; its client view contains only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection use the retained projection handle.
diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md
index 0477b4197e..6afa0a0163 100644
--- a/packages/interaction/permission-presets/README.zh.md
+++ b/packages/interaction/permission-presets/README.zh.md
@@ -86,7 +86,7 @@ kind: "package-reference"
### 读取侧与 `custom`
-`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。
+`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。该服务在激活期间取得状态读取句柄。
`optionOf(name)` 返回配置条目、存活的 Auto 条目或仅供显示的 `custom` 条目。只有名称不匹配这三者时才抛错;已撤回的 Auto 条目不可用。
@@ -96,7 +96,7 @@ kind: "package-reference"
### 目录、投影与可选命令
-该服务要求 `ctx.sessionProjections`,并注册一个只含 `currentValue` 的 `permissions` 投影。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用会在投影 key 缺失时显式失败。
+该服务要求 `ctx.sessionProjections`,并等待 `permissions` 注册完成;其客户端视图只含 `currentValue`。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用使用保留的投影句柄。
diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts
index 8135d74282..a3df0f8d74 100644
--- a/packages/interaction/permission-presets/src/index.ts
+++ b/packages/interaction/permission-presets/src/index.ts
@@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-settings'
import type { Volatile } from '@deepseek-ai/cordis'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand'
import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
@@ -29,7 +29,7 @@ import type {} from '@deepseek-ai/dsh-shell'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
// Type-only: resolves the required projection service and optional settings/command children.
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
import type { PermissionCatalog, PermissionSelection, PresetOption } from './types.ts'
@@ -204,6 +204,8 @@ export class PermissionPresetService extends TypertRemoteService {
private autoAdmit: (() => void) | undefined
private defaultSettings: () => PermissionSettings
+ private projection!: ProjectionHandle
+
constructor(ctx: Context, config: Config) {
super(ctx, 'permissionPresets')
@@ -230,11 +232,14 @@ export class PermissionPresetService extends TypertRemoteService {
if (!Object.hasOwn(this.presets, defaultPreset)) throw new Error(`permission: unknown default preset "${defaultPreset}"`)
return { defaultPreset }
}
+ }
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
const selectionSchema = zod.object({
currentValue: zod.string().min(1),
}) as zod.ZodType
- ctx.sessionProjections.register({
+ this.projection = await ctx.sessionProjections.register({
key: 'permissions',
stateVersion: 2,
stateSchema: permissionStateSchema,
@@ -326,8 +331,7 @@ export class PermissionPresetService extends TypertRemoteService {
}
private permissionState(session: Session): PermissionProjectionState {
- const state = this.ctx.sessionProjections.stateOf(session, 'permissions')
- if (state === undefined) throw new Error('permission: permissions session projection is not registered')
+ const state = this.projection.stateOf(session)
return state
}
diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
index 10de7a4f31..6641d2c259 100644
--- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts
+++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, {
Session,
@@ -65,17 +65,17 @@ describe('permission preset fold', () => {
it('folds the latest preset selection and steps over unrelated events', async () => {
const ctx = await mounted()
const session = freshSession('sess-fold')
- const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null
- expect(presetOf()).toBeNull()
+ const presetOf = () => ctx.sessionProjections.checkpoint(session).permissions?.val
+ expect(presetOf()).toMatchObject({ preset: null })
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('permission/preset', { preset: 'workspace-write' })
- expect(presetOf()).toBe('workspace-write')
+ expect(presetOf()).toMatchObject({ preset: 'workspace-write' })
// The knob fold steps over non-preset events to the latest selection.
session.append('sandbox/mode', { mode: 'read-only' })
- expect(presetOf()).toBe('workspace-write')
+ expect(presetOf()).toMatchObject({ preset: 'workspace-write' })
const seeded = Session.create(SessionId('sess-fold-seeded'), [])
- expect(ctx.sessionProjections.stateOf(seeded, 'permissions')?.seeded).toBe(true)
+ expect(ctx.sessionProjections.checkpoint(seeded).permissions?.val).toMatchObject({ seeded: true })
})
})
@@ -85,13 +85,6 @@ describe('PermissionPresetService', () => {
expect(ctx.get('permissionPresets')).toBeUndefined()
})
- it('fails when the permissions projection key is absent', async () => {
- const ctx = await mounted()
- vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
- expect(() => ctx.permissionPresets.current(freshSession('missing-permission-projection-key')))
- .toThrow('permission: permissions session projection is not registered')
- })
-
it('advertises the preset table in declaration order and resolves bundles', async () => {
const ctx = await mounted()
expect(ctx.permissionPresets.names).toEqual(['workspace-write', 'danger-full-access'])
diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts
index 06cb67b8b6..1a93231646 100644
--- a/packages/interaction/user-questions/src/index.ts
+++ b/packages/interaction/user-questions/src/index.ts
@@ -7,16 +7,16 @@
* @module @deepseek-ai/dsh-user-questions
*/
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent'
import { createUserMessage, HarnessError, type MessageId, type ToolCallId, type UserMessage } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type { Agent } from '@deepseek-ai/dsh-agent'
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import z from '@deepseek-ai/schemastery'
-import { userQuestionProjectionDefinition } from './projection.ts'
+import { userQuestionProjectionDefinition, type UserQuestionProjectionState } from './projection.ts'
import { TimedQuestionWait } from './timed-wait.ts'
declare module '@deepseek-ai/cordis' {
@@ -78,14 +78,12 @@ type QueuedReply = { messageId: MessageId; claimedTurn?: number }
/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */
export class UserQuestionService extends TypertRemoteService {
static Config = z.object({})
+ private projection: ProjectionHandle | undefined
private readonly waits = new Map>()
private readonly queuedReplies = new WeakMap>()
constructor(ctx: Context) {
super(ctx, 'userQuestions')
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register(userQuestionProjectionDefinition)
- })
ctx.effect(() => () => {
for (const calls of this.waits.values()) {
for (const wait of calls.values()) wait.close(abortedQuestion())
@@ -128,6 +126,14 @@ export class UserQuestionService extends TypertRemoteService {
calls.delete(callId)
}
+ protected async [Service.init](): Promise {
+ const binding = this.ctx.inject(['sessionProjections'], async (ctx) => {
+ this.projection = await ctx.sessionProjections.register(userQuestionProjectionDefinition)
+ return () => { this.projection = undefined }
+ })
+ if (this.ctx.get('sessionProjections') !== undefined) await binding
+ }
+
private assertLiveRoot(agent: Agent): void {
const agents = this.ctx.get('agents')
if (agents === undefined || agents.get(agent.id) !== agent) {
@@ -144,8 +150,7 @@ export class UserQuestionService extends TypertRemoteService {
}
private continued(agent: Agent): readonly PendingUserQuestion[] {
- const state = this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions')
- return (state?.questions.active ?? []).filter(question => question.state === 'continued')
+ return this.projection?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? []
}
/**
diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts
index f0f744259a..0cbb2a5031 100644
--- a/packages/interaction/user-questions/tests/user-questions.spec.ts
+++ b/packages/interaction/user-questions/tests/user-questions.spec.ts
@@ -895,7 +895,7 @@ describe('late replies', () => {
error: { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN },
}, { surfaceOp: 'append' })
- expect(ctx.sessionProjections.stateOf(agent.session, 'userQuestions')?.questions).toEqual({ active: [], settled: [] })
+ expect(ctx.sessionProjections.checkpoint(agent.session).userQuestions?.val).toMatchObject({ questions: { active: [], settled: [] } })
expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(false)
expect(agent.steer).not.toHaveBeenCalled()
await ctx.fiber.dispose()
diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts
index 89ffc14c40..60cca5ba2f 100644
--- a/packages/llm/llm-retry/src/index.ts
+++ b/packages/llm/llm-retry/src/index.ts
@@ -19,6 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts'
export { RetryId } from './brand.ts'
export const name = 'llm-retry'
+
export const inject = ['agents', 'sessionProjections']
/** This policy executor has no config; providers own `retryPolicy`. */
@@ -95,12 +96,6 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise {
validateConfig(config)
- ctx.sessionProjections.register({
+ const projection = await ctx.sessionProjections.register({
key: 'llmRetry',
stateVersion: 1,
stateSchema: llmRetryStateSchema,
@@ -217,7 +219,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
}
const policyKey = retryPolicyKey(policy)
- const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') as LlmRetryState
+ const retryState = projection.stateOf(agent.session)
const previous = retryState[retryStateKey(provider, policyKey)]
const previousRetry = previous?.retry ?? 0
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts
index 3adb88de05..43ead12c94 100644
--- a/packages/llm/llm-retry/tests/retry.spec.ts
+++ b/packages/llm/llm-retry/tests/retry.spec.ts
@@ -20,6 +20,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import type { Config as retryConfig, RetryInternals as retryRetryInternals } from '../src/index.ts'
import * as retry from '../src/index.ts'
type ScriptEntry = Error | Iterable | AsyncIterable
@@ -103,7 +104,7 @@ async function harness(
adapter: ScriptedAdapter,
policies: Readonly> = { mock: normalConfig() },
beforeRetry?: (ctx: Context) => void,
- internals: retry.RetryInternals = {},
+ internals: retryRetryInternals = {},
): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
@@ -114,8 +115,8 @@ async function harness(
await ctx.plugin(AgentRegistry)
beforeRetry?.(ctx)
adapter.configureRetryPolicies(policies)
- const retryFiber = await ctx.plugin(Object.assign((inner: Context) => {
- retry.apply(inner, {}, internals)
+ const retryFiber = await ctx.plugin(Object.assign(async (inner: Context) => {
+ await retry.apply(inner, {}, internals)
}, { inject: retry.inject }))
await ctx.plugin(AgentLoop, { agents: [] })
const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter)
@@ -1047,23 +1048,21 @@ describe('provider-routed retry policy', () => {
expect(vi.getTimerCount()).toBe(0)
})
- it('rejects retry policy configured on the executor instead of a provider', () => {
- expectTypeOf<{}>().toExtend()
- expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend()
+ it('rejects retry policy configured on the executor instead of a provider', async () => {
+ expectTypeOf<{}>().toExtend()
+ expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend()
const ctx = new Context()
- // `apply` registers its projection unit first; the registry is a required
- // injection, so the direct-apply path must carry it too.
new SessionProjectionRegistry(ctx)
- expect(() => {
- retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config)
- }).toThrow(/retryPolicy belongs under each provider/)
+ await expect(async () => {
+ await retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retryConfig)
+ }).rejects.toThrow(/retryPolicy belongs under each provider/)
})
- it('rejects unknown executor config', () => {
+ it('rejects unknown executor config', async () => {
const ctx = new Context()
new SessionProjectionRegistry(ctx)
- expect(() => {
- retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config)
- }).toThrow(/unknown key "retryPolciy"/)
+ await expect(async () => {
+ await retry.apply(ctx, { retryPolciy: {} } as unknown as retryConfig)
+ }).rejects.toThrow(/unknown key "retryPolciy"/)
})
})
diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts
index f721d26219..7c1ac24432 100644
--- a/packages/llm/token-meter/src/index.ts
+++ b/packages/llm/token-meter/src/index.ts
@@ -38,6 +38,7 @@ import type { MeterSurfaceNode } from './surface-fold.ts'
import { priceSurface } from './route-pricing.ts'
export type * from './types.ts'
+export { contextPressureProjectionDefinition } from './usage-projection.ts'
// Module-edge re-export: forces the emitted index.d.ts to import the
// projection-unit modules, so their SessionProjectionStateMap augmentations load
// in aggregate programs that only import the package root.
@@ -110,10 +111,13 @@ export class TokenMeter extends Service {
constructor(ctx: Context, config: TokenMeterConfig = {}) {
super(ctx, 'tokenMeter')
validateConfigKeys(config)
+ }
- ctx.sessionProjections.register(tokenUsageProjectionDefinition)
- ctx.sessionProjections.register(contextPressureProjectionDefinition)
- ctx.sessionProjections.register(contextBreakdownProjectionDefinition)
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ await ctx.sessionProjections.register(tokenUsageProjectionDefinition)
+ await ctx.sessionProjections.register(contextPressureProjectionDefinition)
+ await ctx.sessionProjections.register(contextBreakdownProjectionDefinition)
// Readers catch up independently, while eager observation bounds ordinary
// read latency without creating state for sessions no consumer has read.
diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
index d6fee3cb04..8eba5db571 100644
--- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
+++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts
@@ -370,8 +370,9 @@ describe('contextBreakdown session projection', () => {
const first = appendUser(session, 'the first of many messages')
for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`)
const last = appendUser(session, 'the last message before compaction')
+ const projection = await ctx.sessionProjections.register(contextBreakdownProjectionDefinition)
const state = () => {
- const current = ctx.sessionProjections.stateOf(session, 'contextBreakdown')
+ const current = projection.stateOf(session)
if (current === undefined) throw new Error('registered context breakdown has no state')
return current
}
diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts
index bf11f1b039..08959f288e 100644
--- a/packages/llm/token-meter/tests/route-pricing.spec.ts
+++ b/packages/llm/token-meter/tests/route-pricing.spec.ts
@@ -1,5 +1,5 @@
import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, onTestFinished } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import {
LlmRuntime, LlmAdapter, createMessage, createToolResultMessage, createUserMessage, projectFilesToText, ToolCallId,
@@ -76,6 +76,7 @@ interface Harness {
async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise {
const ctx = new Context()
+ onTestFinished(async () => { await ctx.fiber.dispose() })
new SessionProjectionRegistry(ctx)
ctx.provide('attachments', {
fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`,
@@ -85,7 +86,8 @@ async function harness(pricing: (model: string) => LlmImageRequestPricing | unde
} as never)
const llm = new LlmRuntime(ctx)
llm.registerAdapter(['mock'], new PricingAdapter(pricing))
- const meter = new TokenMeter(ctx)
+ await ctx.plugin(TokenMeter)
+ const meter = ctx.tokenMeter
return { ctx, meter, session: Session.create(SessionId('route-priced'), undefined, undefined, undefined, [imageOffloadProjection]) }
}
diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts
index f207905573..818fa45921 100644
--- a/packages/llm/token-meter/tests/token-meter.spec.ts
+++ b/packages/llm/token-meter/tests/token-meter.spec.ts
@@ -106,8 +106,7 @@ function appendSuccessfulCall(
function meter(config: TokenMeterConfig = {}): TokenMeter {
const ctx = new Context()
- // The registry is a required injection of the service (its three projection
- // units register in the constructor); mount it synchronously.
+ // Pure measurement tests use the service without mounting its projection contributions.
new SessionProjectionRegistry(ctx)
return new TokenMeter(ctx, config)
}
@@ -137,8 +136,13 @@ describe('TokenMeter configuration and registration', () => {
await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(TokenMeter)
expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter)
+ const session = ctx.sessions.create()
+ expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort())
+ .toEqual(['contextBreakdown', 'contextPressure', 'tokenUsage'])
await fiber.dispose()
expect(ctx.get('tokenMeter')).toBeUndefined()
+ expect(ctx.sessionProjections.checkpoint(session)).toEqual({})
+ await ctx.fiber.dispose()
})
})
diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml
index fcd9bc5201..5123d81ae9 100644
--- a/packages/plan/plan-mode/README.i18n.yaml
+++ b/packages/plan/plan-mode/README.i18n.yaml
@@ -39,8 +39,8 @@
en: 0acc88b042a99380
zh: e4cf66b77f2fc7ae
/deepseek-ai-dsh-plan-mode/understand-the-implementation/durable-state-and-step-boundary-appends:
- en: ff946ab59a7b4a0f
- zh: b1c79937770f57ad
+ en: 24166a02b6e81553
+ zh: 24313c2153c9a6a6
/deepseek-ai-dsh-plan-mode/understand-the-implementation/the-plan-command:
en: a7bf35c2a9da52bc
zh: dea9f4b3a41f1a6d
@@ -48,8 +48,8 @@
en: 56798d0f45fb5775
zh: 282e4a522ce6dae0
/deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit:
- en: 74cd6bdf50af51f5
- zh: 67609cf7202ececb
+ en: 42189cbdbd1ef615
+ zh: 711142761cc29491
/deepseek-ai-dsh-plan-mode/understand-the-implementation/source-map:
en: fa97e858495a8105
zh: 8be1fbd7e420ff8a
diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md
index 35084ddb60..dce61a5e38 100644
--- a/packages/plan/plan-mode/README.md
+++ b/packages/plan/plan-mode/README.md
@@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac
### Durable state and step-boundary appends
-The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the registered `plan` projection; the first dependent access fails explicitly when the registry or key is absent.
+The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the retained `plan` projection handle, acquired during activation.
### The `/plan` command
@@ -94,7 +94,7 @@ The command child activates only when a commands service is composed. It maps ba
### Session projection unit
-When `ctx.sessionProjections` is composed, the package registers the `plan` unit through optional injection. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. Plan-mode reads require this unit and the `turnBoundary` unit, and fail explicitly if the registry or either key is absent.
+The package requires `ctx.sessionProjections` and awaits registration of the `plan` and `turnBoundary` units during activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handles and remain available after unregistration.
### Source map
diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md
index 9d7023df69..b325573434 100644
--- a/packages/plan/plan-mode/README.zh.md
+++ b/packages/plan/plan-mode/README.zh.md
@@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
### 持久状态与步骤边界追加
-本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并读取已注册的 `plan` 投影;注册表或 key 缺失时,第一次依赖它们的访问会显式失败。
+本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并通过激活期间取得并保留的 `plan` 投影句柄读取状态。
### `/plan` 命令
@@ -94,7 +94,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
### 会话投影单元
-组合了 `ctx.sessionProjections` 时,本包通过可选注入注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。plan-mode 读取要求该单元与 `turnBoundary` 单元存在;注册表或任一 key 缺失时都会显式失败。
+本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 与 `turnBoundary` 单元注册完成。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。
### 源码地图
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index a2385491ef..6f05c60d1d 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -25,6 +25,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { brandString } from '@deepseek-ai/dsh-brand'
import { z as zod } from 'zod'
import type { ZodType } from 'zod'
+import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContextFormed } from '@deepseek-ai/dsh-llm'
@@ -33,7 +34,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
import { UserQuestionError, type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions'
import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands'
import type {} from '@deepseek-ai/dsh-session-projection'
-import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { PlanProjection, PlanUnitState } from './types.ts'
declare module '@deepseek-ai/dsh-llm' {
interface MessageSourceMap {
@@ -177,6 +178,8 @@ export class PlanModeController extends Service {
/** Validated deployment-owned guidance. */
private readonly section: string
+ private projection!: ProjectionHandle
+ private turnBoundary!: ProjectionHandle
/**
* Latest selection per session awaiting the next accepted in-turn pre-step.
@@ -188,6 +191,12 @@ export class PlanModeController extends Service {
constructor(ctx: Context, config: PlanModeConfig = { section: '' }) {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ this.projection = await ctx.sessionProjections.register(planProjectionDefinition)
+ this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
let disposed = false
// Pre-step is outside Session.append publication, so it can append the
// log-only mode event inside an open turn without re-entering the session.
@@ -223,7 +232,6 @@ export class PlanModeController extends Service {
},
})
- ctx.sessionProjections.register(planProjectionDefinition)
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
@@ -377,8 +385,7 @@ export class PlanModeController extends Service {
}
private hasOpenTurn(session: Session): boolean {
- const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')
- if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection')
+ const state = this.turnBoundary.stateOf(session)
return state.openTurnStartSeq !== null
}
@@ -388,8 +395,7 @@ export class PlanModeController extends Service {
/** Read the required plan projection state or fail at the first service access. */
private planState(session: Session): PlanUnitState {
- const state = this.ctx.sessionProjections.stateOf(session, 'plan')
- if (state === undefined) throw new Error('plan-mode requires the plan session projection')
+ const state = this.projection.stateOf(session)
return state
}
diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts
index 5c39a792e8..1068721786 100644
--- a/packages/plan/plan-mode/tests/integration.spec.ts
+++ b/packages/plan/plan-mode/tests/integration.spec.ts
@@ -56,8 +56,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise {
/** Read the plan unit that backs the service in this full composition. */
function planActive(ctx: Context, agent: Agent): boolean {
- const state = ctx.sessionProjections.stateOf(agent.session, 'plan')
- if (state === undefined) throw new Error('plan projection is not registered')
+ const state = ctx.planMode.get(agent)
return state.active
}
diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts
index 6024269b6d..2ba26b9a09 100644
--- a/packages/plan/plan-mode/tests/plan-mode.spec.ts
+++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts
@@ -89,7 +89,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo
async function mountProjectionSeam(ctx: Context): Promise {
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
}
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise {
@@ -231,25 +231,6 @@ describe('ctx.planMode: get/set', () => {
expect(ctx.get('planMode')).toBeUndefined()
})
- it('fails when the required plan projection key is absent', async () => {
- const ctx = await setup()
- const agent = await agentWithSession(ctx, 'missing-plan-projection')
- vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined)
- expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the plan session projection')
- })
-
- it('registers plan state directly but requires turnBoundary state', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionProjectionRegistry)
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRuntime)
- const agent = await agentWithSession(ctx, 'missing-plan-projection-keys')
- const planMode = new PlanModeController(ctx, PLAN_CONFIG)
- await new Promise(resolve => setImmediate(resolve))
- expect(planMode.get(agent)).toEqual({ active: false })
- expect(() => planMode.set(agent, true)).toThrow('plan-mode requires the turnBoundary session projection')
- })
-
it('reads the folded state', async () => {
const ctx = await setup()
const agent = await agentWithSession(ctx)
diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts
index 3c2fc36b5c..99c2b538ea 100644
--- a/packages/plan/plan-mode/tests/projection.spec.ts
+++ b/packages/plan/plan-mode/tests/projection.spec.ts
@@ -118,22 +118,22 @@ describe('plan projection unit', () => {
const bench = await harness(true)
commitPlanMode(bench.session, true, 0)
expect(bench.values().plan).toEqual({ active: true, pending: false })
- expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull()
+ expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: null })
bench.session.append('request/header', {
header: { config: { provider: 'test', model: 'test-model' } },
reason: 'initial',
})
expect(bench.values().plan).toEqual({ active: true, pending: false })
- expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true)
+ expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true })
commitPlanMode(bench.session, false, 1)
expect(bench.values().plan).toEqual({ active: false, pending: false })
- expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true)
+ expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true })
bench.session.append('request/header', {
header: { config: { provider: 'test', model: 'test-model' } },
reason: 'change',
})
expect(bench.values().plan).toEqual({ active: false, pending: false })
- expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false)
+ expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: false })
})
it('has no plan key when plan-mode is not composed', async () => {
diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts
index 0500922904..9f870203b5 100644
--- a/packages/preset/agent-preset-registry/src/index.ts
+++ b/packages/preset/agent-preset-registry/src/index.ts
@@ -1,5 +1,7 @@
/** Declarative Agent capability sets, activation and session binding. */
-import { Context } from '@deepseek-ai/cordis'
+import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { bindScopeParent, createScope, scopeOf, scopeParentOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope'
@@ -55,6 +57,7 @@ export class AgentPresetRegistry extends TypertRemoteService {
default: z.string().required(),
selectedDefault: z.string().volatile(),
})
+ private turnBoundary!: ProjectionHandle
private readonly owner: Context
private readonly definitions = new Map()
private readonly generations = new Map()
@@ -64,7 +67,12 @@ export class AgentPresetRegistry extends TypertRemoteService {
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentPresets')
this.owner = ctx
- ctx.sessionProjections.register(agentPresetProjectionDefinition)
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.owner
+ await ctx.sessionProjections.register(agentPresetProjectionDefinition)
+ this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })
ctx.on('session/event', (session, event) => {
if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset)
@@ -346,8 +354,8 @@ export class AgentPresetRegistry extends TypertRemoteService {
@Remote('select')
async select(agent: Agent, agentPreset: string): Promise {
const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => {
- const boundary = this.owner.sessionProjections.stateOf(agent.session, 'turnBoundary')
- if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) {
+ const boundary = this.turnBoundary.stateOf(agent.session)
+ if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) {
throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset })
}
const preset = await this.recompose(agent.ctx, agentPreset)
diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts
index 04ab9c0096..471998f689 100644
--- a/packages/preset/agent-preset-registry/tests/registry.spec.ts
+++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts
@@ -108,7 +108,7 @@ describe('declarative preset revisions', () => {
await declare(ctx, contribution('minimal'))
const agent = await agentOn(ctx, 'selection')
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
- expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal')
+ expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal')
agent.session.append('turn/start', { turn: 1 })
await expect(ctx.agentPresets.select(agent, 'standard')).rejects.toThrow('already started')
})
@@ -276,7 +276,7 @@ it('serializes competing blank-session selections and recovers after a failed se
ctx.agentPresets.select(agent, 'absent'), ctx.agentPresets.select(agent, 'minimal'), ctx.agentPresets.select(agent, 'standard'),
])
expect(results.map(row => row.status)).toEqual(['rejected', 'fulfilled', 'fulfilled'])
- expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard')
+ expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('standard')
})
it('retains a newly acquired generation when a previous activation is replaced', async () => {
@@ -305,7 +305,7 @@ it('does not lose the durable selection when a tools observer rejects notificati
const agent = await agentOn(ctx, 'observer')
ctx.on('tools/change', () => { throw new Error('observer refused') })
expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal')
- expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal')
+ expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal')
})
it('inventories failed conditional entries without executing their expressions again', async () => {
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index e97796db5c..c966e6a4ea 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -27,7 +27,7 @@ import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-system-prompt'
export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts'
@@ -122,6 +122,8 @@ export class SandboxPolicyService extends Service {
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
readonly workspaceRoot: string
+ private projection!: ProjectionHandle
+
constructor(ctx: Context, config: Config) {
super(ctx, 'sandboxPolicy')
// schemastery (static Config) already filled `mode`; the cast records that
@@ -129,8 +131,11 @@ export class SandboxPolicyService extends Service {
// the process cwd is real branching, resolved absolute either way.
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
+ }
- ctx.sessionProjections.register({
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ this.projection = await ctx.sessionProjections.register({
key: 'sandboxMode',
stateVersion: 1,
stateSchema: sandboxModeStateSchema,
@@ -176,7 +181,7 @@ export class SandboxPolicyService extends Service {
* @returns the last logged mode, or `undefined` without one.
*/
overrideOf(session: Session): SandboxMode | undefined {
- return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined
+ return this.projection.stateOf(session) ?? undefined
}
}
diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
index a6ed4d9fb4..e2279e7fd7 100644
--- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts
+++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
@@ -235,10 +235,10 @@ describe('the sandbox/mode session kit', () => {
it('the sandboxMode projection folds to the last switch, or null without one', async () => {
const ctx = await mounted()
const session = Session.create(SessionId('sess-fold'))
- expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull()
+ expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBeNull()
setSandboxMode(session, 'workspace-write')
setSandboxMode(session, 'read-only')
- expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only')
+ expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBe('read-only')
})
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts
index 54679b6e78..6439070f01 100644
--- a/packages/session/session-stats/src/index.ts
+++ b/packages/session/session-stats/src/index.ts
@@ -14,16 +14,16 @@ import { sessionStatsProjectionDefinition } from './projection.ts'
export type * from './types.ts'
-/** Cordis plugin name. */
export const name = 'session-stats'
-/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
+
export const inject = ['sessionProjections']
/**
* Register the `sessionStats` unit; the registration is an effect on this
* plugin's fiber, so unloading removes the key.
* @param ctx - registrant context carrying the projection registry.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context): void {
- ctx.sessionProjections.register(sessionStatsProjectionDefinition)
+export async function apply(ctx: Context): Promise {
+ await ctx.sessionProjections.register(sessionStatsProjectionDefinition)
}
diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts
index 35c297eddc..92565e1cc9 100644
--- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts
+++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts
@@ -76,7 +76,7 @@ async function harness(
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter(script, reasoningEfforts)
ctx.llm.registerAdapter(['title-route'], adapter)
@@ -249,7 +249,7 @@ describe('first-prompt LLM title provider', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
// Delegate to the real registration while capturing it, to reach the guard
// the service never produces.
diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts
index 6a7e9acdde..3d5dc3d7e0 100644
--- a/packages/session/session-title/src/index.ts
+++ b/packages/session/session-title/src/index.ts
@@ -3,6 +3,7 @@
* @module @deepseek-ai/dsh-session-title
*/
+import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
@@ -17,8 +18,7 @@ import type {
} from '@deepseek-ai/dsh-session'
import { SessionSeq } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-projection'
-import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
-import type {} from '@deepseek-ai/dsh-agent'
+import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
export type {
SessionTitleEventData,
SessionTitleModelIdentity,
@@ -306,6 +306,8 @@ export class SessionTitleService extends Service {
maxTitleBytes: z.number().step(1).min(1).required(),
})
+ private turnBoundary!: ProjectionHandle
+ private titleInput!: ProjectionHandle
private readonly config: ResolvedConfig
private readonly ownerFiber: Fiber
private registration: ProviderRegistration | undefined
@@ -328,6 +330,11 @@ export class SessionTitleService extends Service {
throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
}
this.config = deepFreeze({ ...value })
+ }
+
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
+ this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
ctx.effect(() => async () => {
this.lifetime.abort(new Error('session-title service disposed'))
@@ -341,9 +348,9 @@ export class SessionTitleService extends Service {
this.work.clear()
}, 'sessionTitle lifecycle')
- ctx.sessionProjections.register(titleProjectionDefinition)
+ await ctx.sessionProjections.register(titleProjectionDefinition)
- ctx.sessionProjections.register<'titleInput', TitleInputState>({
+ this.titleInput = await ctx.sessionProjections.register<'titleInput', TitleInputState>({
key: 'titleInput',
stateVersion: 3,
stateSchema: titleInputStateSchema,
@@ -549,7 +556,7 @@ export class SessionTitleService extends Service {
const state = session === undefined ? undefined : this.work.get(session)
const pending = state?.pending
if (session === undefined || state === undefined || pending === undefined) return
- const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary
+ const boundary = this.turnBoundary.stateOf(session).lastStepBoundary
const route = session.requestHeader()?.config
if (boundary?.kind !== 'start'
|| route?.provider !== options.provider
@@ -730,7 +737,7 @@ export class SessionTitleService extends Service {
}
private titleInputOf(session: Session): TitleInputState {
- return this.ctx.sessionProjections.stateOf(session, 'titleInput') as TitleInputState
+ return this.titleInput.stateOf(session)
}
/** Queue detached service work and retain it through service disposal. */
diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts
index df2b5f1f91..784df3c69d 100644
--- a/packages/session/session-title/tests/projection.spec.ts
+++ b/packages/session/session-title/tests/projection.spec.ts
@@ -90,10 +90,8 @@ describe('title projection unit', () => {
}
await ctx.plugin(SessionTitleService, CONFIG)
- const state = ctx.sessionProjections.stateOf(session, 'titleInput')
- expect(state?.count).toBe(5_000)
- expect(state?.first?.text).toBe('message 0')
- expect(state?.lastSeq).toBe(session.seq - 1)
+ const state = ctx.sessionProjections.checkpoint(session).titleInput?.val
+ expect(state).toMatchObject({ count: 5_000, first: { text: 'message 0' }, lastSeq: session.seq - 1 })
expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined()
})
diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts
index 8f22a930fc..f08002e145 100644
--- a/packages/session/session-title/tests/provider.spec.ts
+++ b/packages/session/session-title/tests/provider.spec.ts
@@ -452,7 +452,7 @@ describe('SessionTitleService Provider lifecycle', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
@@ -508,7 +508,7 @@ describe('SessionTitleService Provider lifecycle', () => {
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({
title: 'Unexpected title',
diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json
index 5755ac55e2..e056eda2bd 100644
--- a/packages/session/session-title/tsconfig.json
+++ b/packages/session/session-title/tsconfig.json
@@ -31,7 +31,6 @@
},
{
"path": "../../core/agent"
-
}
]
}
diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts
index 0105e7c186..ab2c5f667e 100644
--- a/packages/session/session-turn-outline/src/index.ts
+++ b/packages/session/session-turn-outline/src/index.ts
@@ -14,16 +14,16 @@ import { turnOutlineProjectionDefinition } from './projection.ts'
export type * from './types.ts'
-/** Cordis plugin name. */
export const name = 'session-turn-outline'
-/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */
+
export const inject = ['sessionProjections']
/**
* Register the `turnOutline` unit; the registration is an effect on this
* plugin's fiber, so unloading removes the key.
* @param ctx - registrant context carrying the projection registry.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context): void {
- ctx.sessionProjections.register(turnOutlineProjectionDefinition)
+export async function apply(ctx: Context): Promise {
+ await ctx.sessionProjections.register(turnOutlineProjectionDefinition)
}
diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts
index c01acf8aa5..8c1d26bc96 100644
--- a/packages/session/session-turn-outline/tests/projection.spec.ts
+++ b/packages/session/session-turn-outline/tests/projection.spec.ts
@@ -97,7 +97,7 @@ describe('turn outline projection unit', () => {
appendPrompt(session, 'prompt')
appendAssistant(session, 1, 1, 'streamed but unsettled')
expect(outlineOf(ctx, session)[0]?.response).toBe('')
- expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled')
+ expect(ctx.sessionProjections.checkpoint(session).turnOutline?.val).toMatchObject({ draft: 'streamed but unsettled' })
endTurn(session, 1)
expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled')
})
diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts
index 7346feb274..60c8ad9c2a 100644
--- a/packages/session/working-directory/src/index.ts
+++ b/packages/session/working-directory/src/index.ts
@@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-fs'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session } from '@deepseek-ai/dsh-session'
-import type {} from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/cordis' {
@@ -72,14 +72,19 @@ export class WorkingDirectoryService extends Service {
private readonly operations = new Set>()
private readonly lifecycle = new AbortController()
+ private projection!: ProjectionHandle
+
constructor(ctx: Context, config: Config) {
super(ctx, 'workingDirectory')
const directory = config.defaultDirectory ?? process.cwd()
if (!isAbsolute(directory)) throw new Error('working-directory: defaultDirectory must be absolute')
this.defaultDirectory = directory
+ }
+ protected async [Service.init](): Promise {
+ const ctx = this.ctx
const directorySchema = zod.string().min(1).refine(isAbsolute).nullable()
- ctx.sessionProjections.register({
+ this.projection = await ctx.sessionProjections.register({
key: 'workingDirectory',
stateVersion: 1,
stateSchema: directorySchema,
@@ -120,7 +125,7 @@ export class WorkingDirectoryService extends Service {
* @returns its effective absolute directory.
*/
get(session: Session): string {
- return this.ctx.sessionProjections.stateOf(session, 'workingDirectory') ?? this.defaultDirectory
+ return this.projection.stateOf(session) ?? this.defaultDirectory
}
/**
@@ -139,7 +144,7 @@ export class WorkingDirectoryService extends Service {
control.throwIfAborted()
agent.ctx.fiber.assertActive()
if (info?.type === 'directory') {
- if (this.ctx.sessionProjections.stateOf(agent.session, 'workingDirectory') === null) {
+ if (this.projection.stateOf(agent.session) === null) {
agent.session.append('working-directory/change', { cwd: current })
}
return current
diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts
index 1d01640cd0..1f2eb7d2e8 100644
--- a/packages/shell/tool-bash/tests/tools.spec.ts
+++ b/packages/shell/tool-bash/tests/tools.spec.ts
@@ -211,7 +211,7 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolJobs)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
if (withApproval) await ctx.plugin(ApprovalService)
diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts
index d033c52535..aaa82411c5 100644
--- a/packages/shell/tool-pwsh/tests/tools.spec.ts
+++ b/packages/shell/tool-pwsh/tests/tools.spec.ts
@@ -228,7 +228,7 @@ async function setupSandboxed(withApproval = false) {
// The loop's turnBoundary unit (the open-turn fold) is not mounted in this
// bench — the loop itself is not composed. Register its open-turn fold so
// the approval service's turn-enclosure gate reads the seeded log shape.
- ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+ await ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(ConfiningFakeBash)
if (withApproval) await ctx.plugin(ApprovalService)
diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts
index cd30075474..2e348c865f 100644
--- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts
+++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts
@@ -15,8 +15,8 @@ export const inject = ['agents', 'subagentModelSelection']
*/
export function apply(ctx: Context, config: Config): void {
const install = (agent: Agent): void => {
- agent.ctx.inject(ToolSubagent.inject, (runtimeCtx) => {
- ToolSubagent.apply(runtimeCtx, {
+ agent.ctx.inject(ToolSubagent.inject, async (runtimeCtx) => {
+ await ToolSubagent.apply(runtimeCtx, {
provider: config.provider,
modelSelectionSettings: true,
...(config.toolName === undefined ? {} : { toolName: config.toolName }),
diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts
index c4b1781f57..891e860b16 100644
--- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts
+++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts
@@ -878,6 +878,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
it('requires an explicit absolute Harness home for nested dsh runtimes', async () => {
const ctx = new Context()
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
@@ -898,6 +899,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
])('rejects an invalid $field at load', async ({ field, override }) => {
const ctx = new Context()
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml
index dfb659c93b..c4fef66681 100644
--- a/packages/subagent/subagent/README.i18n.yaml
+++ b/packages/subagent/subagent/README.i18n.yaml
@@ -18,8 +18,8 @@
en: 924b8c15f9529028
zh: 9fb0c399b5a79e3e
/deepseek-ai-dsh-subagent/use-this-package/enabling-delegation:
- en: 388ae5427c869173
- zh: aff830f4986cf1b3
+ en: 025605eddae02478
+ zh: 5659cba4340735d8
/deepseek-ai-dsh-subagent/use-this-package/delegation-settings:
en: 86c982223fab1a3a
zh: 3d220d8ed84eab9a
diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md
index 2442af74c1..77e2e5cab9 100644
--- a/packages/subagent/subagent/README.md
+++ b/packages/subagent/subagent/README.md
@@ -31,7 +31,7 @@ This package is the contract every delegation setup shares. You enable it by mou
### Enabling delegation
-Mount the service with a provider and the delegation tool. The provider registers under its configured name; each tool row selects one provider. A minimal setup:
+Mount the service with `@deepseek-ai/dsh-session-projection`, a provider, and the delegation tool; the service waits for the projection registry before initializing. The provider registers under its configured name; each tool row selects one provider. A minimal setup:
```yaml
- name: '@deepseek-ai/dsh-subagent'
diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md
index 7dccf24550..6506779e83 100644
--- a/packages/subagent/subagent/README.zh.md
+++ b/packages/subagent/subagent/README.zh.md
@@ -31,7 +31,7 @@ kind: "package-reference"
### 启用委派
-挂载服务、提供方与委派工具。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下:
+一起挂载本服务、`@deepseek-ai/dsh-session-projection`、提供方与委派工具;本服务等待投影注册表可用后才初始化。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下:
```yaml
- name: '@deepseek-ai/dsh-subagent'
diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json
index 5a802075d1..5645775408 100644
--- a/packages/subagent/subagent/package.json
+++ b/packages/subagent/subagent/package.json
@@ -94,9 +94,6 @@
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
- "@deepseek-ai/dsh-session-projection": {
- "optional": true
- },
"@deepseek-ai/dsh-session-query": {
"optional": true
},
diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts
index edce6b7ae5..7484cdd5dc 100644
--- a/packages/subagent/subagent/src/index.ts
+++ b/packages/subagent/subagent/src/index.ts
@@ -29,7 +29,7 @@
*/
import type { Volatile } from '@deepseek-ai/cordis'
-import { Context } from '@deepseek-ai/cordis'
+import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-working-directory'
import type {} from '@deepseek-ai/dsh-attachment'
@@ -194,7 +194,7 @@ export class SubagentRuntime extends TypertRemoteService {
maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1).volatile(),
maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8).volatile(),
})
- static inject = ['workingDirectory']
+ static inject = ['workingDirectory', 'sessionProjections']
private providers = new Map()
private manager: SubagentManager | undefined
/**
@@ -222,16 +222,18 @@ export class SubagentRuntime extends TypertRemoteService {
if (this.manager === manager) this.manager = undefined
}, 'subagents.managerBinding()')
})
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition)
- projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
- projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition)
- })
// Archive admission: this runtime is the owner that knows which live
// children descend from a Session and how a parent stops them.
ctx.inject(['agents'], (agentsCtx: Context) => { installSubagentArchiveAdmission(agentsCtx) })
}
+ protected async [Service.init](): Promise {
+ const { sessionProjections } = this.ctx
+ await sessionProjections.register(subagentCatalogProjectionDefinition)
+ await sessionProjections.register(subagentTimingProjectionDefinition)
+ await sessionProjections.register(subagentIdentityProjectionDefinition)
+ }
+
/**
* Resolve a delegation tool's depth policy against the current user setting.
* @param configured - Explicit tool limit, or provider-managed for external delegation.
diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts
index c242f1c423..ac576639fa 100644
--- a/packages/subagent/subagent/tests/catalog.spec.ts
+++ b/packages/subagent/subagent/tests/catalog.spec.ts
@@ -79,7 +79,7 @@ describe('subagent catalog projection', () => {
try {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
+ await ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
const parent = ctx.sessions.create(header.id)
const changes = vi.fn()
ctx.sessionProjections.onChanged(changes)
@@ -196,7 +196,7 @@ describe('subagent catalog projection', () => {
try {
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
+ await ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
const events = [
fact(0, 'valid-child', 0, { mode: 'one-shot' }),
{ type: 'subagent/catalog', seq: SessionSeq(1), time: 0, data } as unknown as SessionEvent,
diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
index e44c170dae..9a764ad01d 100644
--- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
+++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
@@ -80,7 +80,7 @@ function policyEvents(events: readonly SessionEvent[]) {
}
function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown {
- return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode')
+ return ctx.sandboxPolicy.overrideOf(Session.create(id, events)) ?? null
}
function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown {
diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts
index e4ee8e6c89..d7c4477c5b 100644
--- a/packages/subagent/subagent/tests/continuation.spec.ts
+++ b/packages/subagent/subagent/tests/continuation.spec.ts
@@ -1,3 +1,4 @@
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
@@ -100,6 +101,7 @@ async function setupWith(
await ctx.plugin(AgentLoop, { agents: [] })
if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery)
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime,
options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents }))
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
@@ -262,6 +264,7 @@ describe('continuable activation capacity', () => {
const ctx = new Context()
try {
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await expect(ctx.plugin(SubagentRuntime, { maxActiveSubagents })).rejects.toThrow()
} finally {
await ctx.fiber.dispose()
@@ -272,6 +275,7 @@ describe('continuable activation capacity', () => {
const ctx = new Context()
try {
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
const live = await liveConfig(ctx, SubagentRuntime, { maxDepth: 4 })
subagentConfigs.set(ctx, live)
const fiber = live.fiber
@@ -3861,6 +3865,7 @@ describe('continuable errors', () => {
})
await ctx.plugin(AgentLoop, { agents: [] })
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
const serviceFiber = await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts
index b6df7c53ed..16263f5b10 100644
--- a/packages/subagent/subagent/tests/control.spec.ts
+++ b/packages/subagent/subagent/tests/control.spec.ts
@@ -1,3 +1,4 @@
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
// The browser-facing prompt and interrupt controls and their stable failure
// codes. Session Controller owns catalog observation and transport.
@@ -25,6 +26,7 @@ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width
async function bench(live?: Record) {
const ctx = new Context()
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
if (live !== undefined) {
ctx.provide('agents', {
diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts
index dd5cf76799..f9983b580c 100644
--- a/packages/subagent/subagent/tests/list-children.spec.ts
+++ b/packages/subagent/subagent/tests/list-children.spec.ts
@@ -1,3 +1,4 @@
+import { subagentCatalogProjectionDefinition } from '../src/catalog.ts'
import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
@@ -49,6 +50,7 @@ async function setup(
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(TestSessionQuery)
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
@@ -191,6 +193,7 @@ describe('SubagentRuntime.listChildren', () => {
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TestSessionQuery)
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const ancestor = ctx.sessions.create(SessionId('catalog-ancestor'))
ancestor.append('turn/start', { turn: 1 })
@@ -221,6 +224,7 @@ describe('SubagentRuntime.listChildren', () => {
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TestSessionQuery)
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const parent = ctx.sessions.create(SessionId('chunk-parent'))
for (let index = 0; index < 1_001; index += 1) {
@@ -235,7 +239,8 @@ describe('SubagentRuntime.listChildren', () => {
})
}
const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions')
- const state = ctx.sessionProjections.stateOf(parent, 'subagentCatalog')
+ const projection = await ctx.sessionProjections.register(subagentCatalogProjectionDefinition)
+ const state = projection.stateOf(parent)
const chunkLengths: number[] = []
for (let chunk = state?.head; chunk !== undefined; chunk = chunk.previous) {
chunkLengths.push(chunk.values.length)
@@ -253,6 +258,7 @@ describe('SubagentRuntime.listChildren', () => {
const withoutProjection = new Context()
await withoutProjection.plugin(SessionStore)
await mountWorkingDirectoryFixture(withoutProjection)
+ await withoutProjection.plugin(SessionProjectionRegistry)
await withoutProjection.plugin(SubagentRuntime)
const parent = withoutProjection.sessions.create(SessionId('parent'))
await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({
@@ -441,6 +447,7 @@ describe('SubagentRuntime.listDescendants', () => {
it('fails before reading when the Session store or query service is absent', async () => {
const ctx = new Context()
await mountWorkingDirectoryFixture(ctx)
+ if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' })
await ctx.plugin(SessionStore)
diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts
index 8025ac0060..30a6cfc932 100644
--- a/packages/subagent/subagent/tests/service.spec.ts
+++ b/packages/subagent/subagent/tests/service.spec.ts
@@ -70,8 +70,6 @@ const contexts = new WeakMap()
async function service(withAgents = true): Promise<{ ctx: Context; subagents: SubagentRuntime }> {
const ctx = new Context()
- // The registry is a required injection of SubagentRuntime (its projection
- // units register in the constructor).
if (withAgents) {
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
@@ -119,7 +117,7 @@ describe('SubagentRuntime', () => {
await fiber.dispose()
- expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')).toBeUndefined()
+ expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined()
})
it('registers, lists, looks up, starts, and removes providers', async () => {
diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts
index 40b21431ec..dd8af27c69 100644
--- a/packages/subagent/subagent/tests/timing-projection.spec.ts
+++ b/packages/subagent/subagent/tests/timing-projection.spec.ts
@@ -38,12 +38,14 @@ describe('subagent timing projection', () => {
expect(subagentIdentityProjectionDefinition.wire.view(state)).toEqual({ mode: 'one-shot', seq: 1 })
})
- it('registers with the optional session projection registry', async () => {
+ it('waits for the session projection registry and releases its projections on unload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
await mountWorkingDirectoryFixture(ctx)
- const serviceFiber = await ctx.plugin(SubagentRuntime)
+ const pending = ctx.plugin(SubagentRuntime)
+ expect(ctx.get('subagents')).toBeUndefined()
+ await ctx.plugin(SessionProjectionRegistry)
+ const serviceFiber = await pending
const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values
expect(before.subagentTiming).toEqual({ settledMs: 0 })
diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts
index 968b68ebf3..11df129bb1 100644
--- a/packages/subagent/tool-subagent/src/index.ts
+++ b/packages/subagent/tool-subagent/src/index.ts
@@ -33,11 +33,12 @@ import { registerListSubagentModels } from './list-models.ts'
import type {} from './model-selection-settings.ts'
import {
recordSubagentModelSelection,
- subagentModelSelectionProjectionDefinition,
subagentModelSelectionPolicy,
+ subagentModelSelectionProjectionDefinition,
} from './model-selection-state.ts'
export const name = 'tool-subagent'
+
export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections']
// Definition identity keeps shared guidance scoped to the actual visible tools,
@@ -159,8 +160,9 @@ function providerWording(inheritsConversation: boolean): { description: string;
* @param ctx - Context that owns the registrations.
* @param config - delegation-tool configuration.
* @param session - unpublished Session supplied by a direct Agent setup; omit for a standing composition.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context, config: Config, session?: Session): void {
+export async function apply(ctx: Context, config: Config, session?: Session): Promise {
// Direct apply() bypasses Schemastery's numeric constraints. A direct-apply
// omission stays capless (the schema default only runs through the loader).
if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth)
@@ -171,7 +173,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
const toolName = config.toolName ?? 'subagent'
const modelSelectionCapable = config.modelSelectionSettings === true
- ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
+ const modelSelectionProjection = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => {
if (ctx.subagents.resolveMaxDepth(config.maxDepth) !== undefined && !subagentProvider.capabilities.depthLimit) {
@@ -401,7 +403,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
const freshSession = target.firstLiveSeq === 0
// oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred.
&& target.eventAt(SessionSeq(0))?.type !== 'session/end-seed'
- let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target)
+ let allowedModels = subagentModelSelectionPolicy(modelSelectionProjection, target)
if (allowedModels === undefined) {
const parentId = target.header.origin === 'subagent'
? target.header.parentSession
@@ -414,14 +416,14 @@ export function apply(ctx: Context, config: Config, session?: Session): void {
const parent = sessions.get(parentId)
allowedModels = parent === undefined
? undefined
- : subagentModelSelectionPolicy(ctx.sessionProjections, parent)
+ : subagentModelSelectionPolicy(modelSelectionProjection, parent)
} else if (freshSession) {
const current = settings.current()
allowedModels = current.enabled ? current.allowedModels : undefined
}
}
if (allowedModels !== undefined) {
- recordSubagentModelSelection(ctx.sessionProjections, target, allowedModels)
+ recordSubagentModelSelection(modelSelectionProjection, target, allowedModels)
}
return allowedModels === undefined ? undefined : { routes: allowedModels }
}
diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts
index d3b04b828b..9b51f384f7 100644
--- a/packages/subagent/tool-subagent/src/model-selection-state.ts
+++ b/packages/subagent/tool-subagent/src/model-selection-state.ts
@@ -2,7 +2,7 @@
import { z as zod } from 'zod'
import type { Session } from '@deepseek-ai/dsh-session'
-import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts'
@@ -52,29 +52,29 @@ export const subagentModelSelectionProjectionDefinition = {
/**
* Read the exact route list captured for a model-selectable definition.
- * @param projections - registry that owns the policy projection.
+ * @param projection - registered policy state reader.
* @param session - session whose durable decision is read.
* @returns a detached route list, or undefined for the fixed-route definition.
*/
export function subagentModelSelectionPolicy(
- projections: Pick,
+ projection: Pick, 'stateOf'>,
session: Session,
): AllowedModelRoute[] | undefined {
- return projections.stateOf(session, 'subagentModelSelectionPolicy')?.map(route => ({ ...route }))
+ return projection.stateOf(session)?.map(route => ({ ...route }))
}
/**
* Append the route policy once, before its definition can reach a model request.
- * @param projections - registry that owns the policy projection.
+ * @param projection - registered policy state reader.
* @param session - session receiving the model-selectable definition.
* @param allowedModels - exact routes the definition may select explicitly.
*/
export function recordSubagentModelSelection(
- projections: Pick,
+ projection: Pick, 'stateOf'>,
session: Session,
allowedModels: readonly AllowedModelRoute[],
): void {
- if (subagentModelSelectionPolicy(projections, session) !== undefined) return
+ if (subagentModelSelectionPolicy(projection, session) !== undefined) return
session.append('subagent/model-selection-policy', {
allowedModels: allowedModels.map(route => ({ ...route })),
})
diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts
index 422d65ba0b..7547d77216 100644
--- a/packages/subagent/tool-subagent/tests/harness.ts
+++ b/packages/subagent/tool-subagent/tests/harness.ts
@@ -8,6 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as mock from './scripted-provider.ts'
+import type { Config as toolConfig } from '../src/index.ts'
import * as tool from '../src/index.ts'
import SubagentModelSelectionConfig from '../src/model-selection-settings.ts'
@@ -32,7 +33,7 @@ const setupProviders = new WeakMap {
- const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => {
- tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session)
+ const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => {
+ await tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session)
})
await fiber.await()
},
diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts
index 31ab63b973..a20bbaa404 100644
--- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts
+++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts
@@ -104,7 +104,7 @@ describe('SubagentModelSelectionConfig', () => {
selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig))
await ctx.plugin(SessionProjectionRegistry)
- ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
+ await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
// A stored policy the service cannot apply fails when it is read, not when the profile row is written.
await selectionConfigs.get(ctx)!.update({ allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS] })
@@ -122,13 +122,14 @@ describe('SubagentModelSelectionConfig', () => {
const invalid = Session.create(SessionId('empty-policy'))
invalid.append('subagent/model-selection-policy', { allowedModels: [] })
- expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, invalid)).toThrow('requires at least one route')
+ const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
+ expect(() => subagentModelSelectionPolicy(policy, invalid)).toThrow('requires at least one route')
const malformed = Session.create(SessionId('malformed-policy'))
malformed.append('subagent/model-selection-policy', {
allowedModels: [{ provider: 1, model: 'fast-model' }],
} as never)
- expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, malformed))
+ expect(() => subagentModelSelectionPolicy(policy, malformed))
.toThrow('requires non-empty provider and model ids')
await ctx.fiber.dispose()
})
@@ -137,14 +138,15 @@ describe('SubagentModelSelectionConfig', () => {
const ctx = await boot()
const disabled = await createAgent(ctx, 'disabled')
expect(selectable(ctx, disabled)).toBe(false)
- expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined()
+ const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
+ expect(subagentModelSelectionPolicy(policy, disabled.session)).toBeUndefined()
await selectionConfigs.get(ctx)!.update({
enabled: true,
allowedModels: ALLOWED_MODELS,
})
const enabled = await createAgent(ctx, 'enabled')
- expect(subagentModelSelectionPolicy(ctx.sessionProjections, enabled.session)).toEqual(ALLOWED_MODELS)
+ expect(subagentModelSelectionPolicy(policy, enabled.session)).toEqual(ALLOWED_MODELS)
expect(selectable(ctx, enabled)).toBe(true)
expect(selectable(ctx, disabled)).toBe(false)
@@ -171,8 +173,8 @@ describe('SubagentModelSelectionConfig', () => {
sessionId: SessionId('direct-agent-setup'),
setup: async (agentCtx, agent) => {
prepared = agent
- const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => {
- tool.apply(runtimeCtx, {
+ const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => {
+ await tool.apply(runtimeCtx, {
provider: 'spawn',
modelSelectionSettings: true,
}, agent.session)
@@ -336,7 +338,8 @@ describe('SubagentModelSelectionConfig', () => {
meta: { parentSession: parent.id, origin: 'subagent' },
})
expect(selectable(ctx, child)).toBe(true)
- expect(subagentModelSelectionPolicy(ctx.sessionProjections, child.session)).toEqual(ALLOWED_MODELS)
+ const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition)
+ expect(subagentModelSelectionPolicy(policy, child.session)).toEqual(ALLOWED_MODELS)
const orphan = await createAgent(ctx, 'orphan', {
meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' },
@@ -355,11 +358,11 @@ describe('SubagentModelSelectionConfig', () => {
})
const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] })
expect(selectable(ctx, resumedEmpty)).toBe(false)
- expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined()
+ expect(subagentModelSelectionPolicy(policy, resumedEmpty.session)).toBeUndefined()
const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() })
expect(selectable(ctx, resumedDisabled)).toBe(false)
- expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined()
+ expect(subagentModelSelectionPolicy(policy, resumedDisabled.session)).toBeUndefined()
await ctx.fiber.dispose()
})
@@ -368,22 +371,22 @@ describe('SubagentModelSelectionConfig', () => {
await mountAgentLoopTestDependencies(withoutSettings)
await mountWorkingDirectoryFixture(withoutSettings)
await withoutSettings.plugin(SubagentRuntime)
- expect(() => {
- tool.apply(withoutSettings, {
+ await expect(async () => {
+ await tool.apply(withoutSettings, {
provider: 'missing',
modelSelectionSettings: true,
maxDepth: 'provider-managed',
})
- }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings')
+ }).rejects.toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings')
await withoutSettings.fiber.dispose()
const withoutAgent = await boot(false)
- expect(() => {
- tool.apply(withoutAgent, {
+ await expect(async () => {
+ await tool.apply(withoutAgent, {
provider: 'spawn',
modelSelectionSettings: true,
})
- }).toThrow('requires a scoped preset Context')
+ }).rejects.toThrow('requires a scoped preset Context')
await withoutAgent.fiber.dispose()
})
@@ -405,13 +408,13 @@ describe('SubagentModelSelectionConfig', () => {
parentSession: SessionId('missing-parent'),
})
- expect(() => {
- tool.apply(ctx, {
+ await expect(async () => {
+ await tool.apply(ctx, {
provider: 'missing',
modelSelectionSettings: true,
maxDepth: 'provider-managed',
}, child)
- }).toThrow('child model-selection inheritance requires the Session registry')
+ }).rejects.toThrow('child model-selection inheritance requires the Session registry')
} finally {
await ctx.fiber.dispose()
}
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index ea090fa0a3..193b7bece0 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -18,6 +18,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts'
import * as mock from './scripted-provider.ts'
+import type { Config as toolConfig } from '../src/index.ts'
import * as tool from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
@@ -325,7 +326,7 @@ describe('dsh-tool-subagent', () => {
},
})
// Direct apply with only `provider` — no toolName, no agentOptions.
- tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' })
+ await tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' })
await new Promise(r => setTimeout(r, 10))
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true)
@@ -347,7 +348,7 @@ describe('dsh-tool-subagent', () => {
// Tool first: no provider yet — the tool must be absent, not broken.
// Direct apply (schema bypass): also covers the waiting-note's default
// toolName fallback, which validated config pre-fills.
- tool.apply(ctx, { provider: 'mock' })
+ await tool.apply(ctx, { provider: 'mock' })
expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false)
// Backend arrives (as a delayed sibling fiber would): the tool appears.
await mock.mountScriptedProvider(ctx, { name: 'mock', reply: 'late but fine' })
@@ -360,7 +361,7 @@ describe('dsh-tool-subagent', () => {
const ctx = await projectedContext()
await mountWorkingDirectoryFixture(ctx)
await ctx.plugin(SubagentRuntime)
- tool.apply(ctx, {
+ await tool.apply(ctx, {
provider: 'later-continuable',
maxDepth: 'provider-managed',
})
@@ -575,7 +576,6 @@ describe('dsh-tool-subagent', () => {
expect(typeof unwrapped.apply).toBe('function')
expect(unwrapped.Config).toBeDefined()
})
-
it('passes persona/toolFilter/maxDepth config through to the start request', async () => {
let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined
const ctx = await projectedContext()
@@ -621,14 +621,14 @@ describe('dsh-tool-subagent', () => {
.rejects.toThrow()
})
- it('validates maxDepth when apply() is invoked directly without Schemastery', async () => {
+ it('validates maxDepth during initialization without Schemastery', async () => {
const ctx = await projectedContext()
- expect(() => {
- tool.apply(ctx, {
+ await expect(async () => {
+ await tool.apply(ctx, {
provider: 'unused',
maxDepth: Number.NaN,
})
- }).toThrow('subagent maxDepth must be a non-negative safe integer')
+ }).rejects.toThrow('subagent maxDepth must be a non-negative safe integer')
})
it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => {
@@ -806,7 +806,7 @@ describe('dsh-tool-subagent local activation', () => {
return {}
},
})
- tool.apply(ctx, {
+ await tool.apply(ctx, {
provider: 'gated',
toolName: 'subagent_gated',
maxDepth: 3,
@@ -850,7 +850,7 @@ describe('dsh-tool-subagent local activation', () => {
describe('depth budget configuration', () => {
/** Mount the tool over a request-capturing provider with full capabilities. */
- async function captureSetup(config: Omit = {}) {
+ async function captureSetup(config: Omit = {}) {
const requests: SubagentStartRequest[] = []
const ctx = await projectedContext()
await mountWorkingDirectoryFixture(ctx)
diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts
index 131e75b553..4b05eeb668 100644
--- a/packages/terminal/terminal-bash/src/index.ts
+++ b/packages/terminal/terminal-bash/src/index.ts
@@ -24,13 +24,12 @@ export type { Config as TerminalLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'terminal-bash'
-/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
-export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory']
+/** Required services: terminal registry, shared confinement policy, process substrate, and working directory. */
+export const inject = ['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory']
interface SandboxModeFenceState {
pty: Context['terminals']
sandboxPolicy: Context['sandboxPolicy']
- sessionProjections: Context['sessionProjections']
}
const sandboxModeFences = new WeakMap()
@@ -40,20 +39,18 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void {
if (existing !== undefined) {
existing.pty = ctx.terminals
existing.sandboxPolicy = ctx.sandboxPolicy
- existing.sessionProjections = ctx.sessionProjections
return
}
const state: SandboxModeFenceState = {
pty: ctx.terminals,
sandboxPolicy: ctx.sandboxPolicy,
- sessionProjections: ctx.sessionProjections,
}
sandboxModeFences.set(owner, state)
owner.ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
const [session, event] = args as [Session, SessionEvent]
if (session !== owner.session || event.type !== 'sandbox/mode') return
- const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null
+ const folded = state.sandboxPolicy.overrideOf(session) ?? null
const currentMode = folded ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts
index a68c798321..9ed1aeeaeb 100644
--- a/packages/terminal/terminal-bash/tests/index.spec.ts
+++ b/packages/terminal/terminal-bash/tests/index.spec.ts
@@ -624,7 +624,7 @@ describe('terminal-bash plugin shape', () => {
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(ptyLocal) as Record
expect(unwrapped.name).toBe('terminal-bash')
- expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory'])
+ expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory'])
expect(unwrapped.Config).toBeDefined()
})
diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts
index 932912b4e4..7cdc154c72 100644
--- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts
+++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts
@@ -110,7 +110,7 @@ describe('dsh-agent-loop-testkit', () => {
time: 1,
data: { target: 'next-turn', start: 99, inserted: [] },
}])
- expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox'))
+ expect(() => ctx.sessionProjections.checkpoint(invalid).inbox?.val)
.toThrow(/invalid persisted inbox splice/)
expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn])
expect(claimed).toEqual([
diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts
index f345045c93..5521005375 100644
--- a/packages/todo/tool-todo/src/index.ts
+++ b/packages/todo/tool-todo/src/index.ts
@@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-session-projection'
export type * from './types.ts'
export const name = 'tool-todo'
+
export const inject = ['tools', 'sessionProjections']
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
@@ -113,14 +114,15 @@ const todosProjectionSchema: ZodType = zod.union([
* `ctx.sessionProjections`.
* @param ctx - registrant context carrying the tool and session-projection registries.
* @param config - deployment's explicit todo policy.
+ * @returns when the plugin is ready.
*/
-export function apply(ctx: Context, config: Config): void {
+export async function apply(ctx: Context, config: Config): Promise {
const allowParallel = config.allowParallelInProgress
// Standing-plan fold: latest whole todo/write list, cleared by the next
// turn/start (turn/end keeps the finished checklist visible); null before the
// first write or after a later turn begins; every other event returns the
// same state reference.
- ctx.sessionProjections.register<'todos', TodoItem[] | null>({
+ await ctx.sessionProjections.register<'todos', TodoItem[] | null>({
key: 'todos',
stateSchema: todosProjectionSchema,
init: () => null,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3a9b79453b..3656df5da8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6783,6 +6783,10 @@ importers:
version: link:../../core/system-prompt
packages/core/agent:
+ dependencies:
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
@@ -9836,6 +9840,9 @@ importers:
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
+ zod:
+ specifier: 4.4.3
+ version: 4.4.3
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~