= z.object({
diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
index 88ab5ede42..823f638fe9 100644
--- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
@@ -11,6 +11,8 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import UserQuestionService from '@deepseek-ai/dsh-user-questions'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
@@ -23,6 +25,10 @@ import { createApiProxy } from '../src/api-proxy.ts'
async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ // No loop in this bench: register the turnBoundary unit so the approval
+ // service's turn-enclosure gate sees the open turn.
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
@@ -211,6 +217,8 @@ describe('approval pending registry', () => {
// effect while an ask is still pending.
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(UserQuestionService)
await ctx.plugin(AgentRegistry)
diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
index 28eaae1b0b..36c131ebe2 100644
--- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
@@ -10,6 +10,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
@@ -30,6 +31,7 @@ function request(payload: P): RpcRequest
{
async function composed(withTitles = true): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserQuestionService)
if (withTitles) {
diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
index a11e2a0416..ec3df497d7 100644
--- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
@@ -233,33 +233,6 @@ describe('subagent gateway', () => {
expect(inspect).not.toHaveBeenCalled()
})
- it('maps the missing projections capability to one wire face on list, history, and prompt', async () => {
- const listError = () => new SubagentError(
- 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
- 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
- )
- const expected = {
- code: 'internal',
- message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
- }
-
- const list = bench({ listError: listError() })
- expect((await list.api.subagents.list(request({ parentSessionId: PARENT }))).result)
- .toMatchObject({ ok: false, error: expected })
-
- const history = bench({ listError: listError() })
- expect((await history.api.subagents.history(request({
- parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable',
- }))).result).toMatchObject({ ok: false, error: expected })
- expect(history.inspect).not.toHaveBeenCalled()
-
- const prompt = bench({ listError: listError() })
- expect((await prompt.api.subagents.prompt(request({
- parentSessionId: PARENT, childSessionId: CHILD, mode: 'continuable', content: [],
- }), new AbortController().signal)).result).toMatchObject({ ok: false, error: expected })
- expect(prompt.followup).not.toHaveBeenCalled()
- })
-
it('routes human content through the exact live parent with rpc attribution', async () => {
const { api, parent, followup } = bench()
const content = [{ type: 'text' as const, text: '继续' }]
diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml
index 7e7359526c..072babf07b 100644
--- a/packages/interaction/permission-presets/README.i18n.yaml
+++ b/packages/interaction/permission-presets/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/permission-presets/README.md
-README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6
-README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621
+README.md: a59b10809c4c93cc5d4b4c3715a17b4661f9e9fe
+README.zh.md: 2e215ca7785c1b08f1bb4c8607aa5e34cf8deb9b
diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md
index 2b671f9e6e..a59b10809c 100644
--- a/packages/interaction/permission-presets/README.md
+++ b/packages/interaction/permission-presets/README.md
@@ -4,13 +4,13 @@ English | [中文](README.zh.md)
User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs.
-`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
+`set(session, name)` records a changed selection in a log-only `permission/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(session)` reads the required `permissions` projection state, prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it.
The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent.
The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md).
-Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed.
+The required `permissions` session-projection unit folds the three whole-value knob events and exposes the full knob state to host readers through `stateOf()`; its client view is the select over composition defaults, with table options plus a current-only `custom`. The optional `/permissionPresets` command activates when `ctx.commands` is composed; bare invocation reports the current preset and table, and a preset argument switches through `set`.
## Model Experience
diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md
index 686138c1f2..2e215ca778 100644
--- a/packages/interaction/permission-presets/README.zh.md
+++ b/packages/interaction/permission-presets/README.zh.md
@@ -4,13 +4,13 @@
通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。
-`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
+`set(session, name)` 会先在仅写日志的 `permission/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(session)` 读取必需的 `permissions` 投影状态,优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。
该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。
该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。
-两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。
+必需的 `permissions` 会话投影单元折叠三个全量值 knob 事件,并通过 `stateOf()` 向 host 读取方提供完整 knob 状态;其客户端视图是基于组合默认值的选择器,包含表内选项与仅作当前值的 `custom`。可选的 `/permissionPresets` 命令在组合了 `ctx.commands` 时激活;不带参数调用会报告当前预设与表,预设参数经 `set` 切换。
## 模型体验
diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts
index 7f06f6e8e6..d871e819c4 100644
--- a/packages/interaction/permission-presets/src/index.ts
+++ b/packages/interaction/permission-presets/src/index.ts
@@ -5,7 +5,7 @@
* and replay keep reading their knob folds. The preset event preserves user
* intent when two presets share a bundle. The read side ships as the
* `permissions` session projection; the write side ships as the
- * `/permission` command — both optional children over the same service.
+ * `/permission` command.
*
* @module dsh-permission-presets
*/
@@ -15,22 +15,18 @@ import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
-import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
// Side-effect type import: declaration-merges `ctx.shell` (the capability fact
// `sandboxMode` this service reads), without a value dependency on the seam.
import type {} from '@deepseek-ai/dsh-shell'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
-import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
-// Type-only: resolves ctx.sessionProjections / ctx.commands for the optional children.
+// Type-only: resolves ctx.sessionProjections and the optional command child.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-commands'
-import type { PermissionSelect, PresetOption } from './types.ts'
+import type { KnobState, PermissionSelect, PresetOption } from './types.ts'
-// The `permissions` projection-key declaration lives in src/types.ts (its one
-// home); this re-export projects the type face onto the package root AND
-// keeps the module edge in the emitted index.d.ts, so aggregate programs
-// consuming the declarations still receive the SessionProjectionMap merge.
export type * from './types.ts'
declare module '@deepseek-ai/cordis' {
@@ -44,7 +40,7 @@ declare module '@deepseek-ai/dsh-session/types' {
/**
* Records the selected preset as durable, log-only user intent. The knob
* events follow in the same turn and control execution; this event stays
- * out of the model transcript and lets {@link effectivePermissionPreset}
+ * out of the model transcript and lets the permission projection unit
* preserve a selection when bundles match.
*/
'permission/preset': { preset: string }
@@ -72,48 +68,19 @@ export const CUSTOM_PRESET = 'custom'
/** Settings namespace carrying the default for future sessions. */
export const PERMISSION_SETTINGS_NAMESPACE = settingsNamespace('permission')
-/**
- * Fold the last selected preset from the durable log; replay needs no catch-up
- * state.
- * @param events - session events in log order; other event types are ignored.
- * @returns the last selected preset, or undefined when none was recorded.
- */
-export function effectivePermissionPreset(events: readonly SessionEvent[]): string | undefined {
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const event = events[index] as SessionEvent
- if (event.type === 'permission/preset') return event.data.preset
- }
- return undefined
-}
+const sandboxField = zod.union([
+ zod.literal('read-only'),
+ zod.literal('workspace-write'),
+ zod.literal('danger-full-access'),
+]).nullable()
-/**
- * The projection unit's state: the last seen value of each knob event, null
- * before an override (composition defaults apply at view time). Plain JSON
- * (persisted-cache precondition).
- */
-export interface KnobState {
- /** Last `permission/preset` payload, or null. */
- preset: string | null
- /** Last `sandbox/mode` payload, or null. */
- sandbox: SandboxMode | null
- /** Last `approval/policy` payload, or null. */
- approval: ApprovalPolicy | null
-}
-
-declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionStateMap {
- permissions: KnobState
- }
-}
+const approvalField = zod.union([zod.literal('ask'), zod.literal('never')]).nullable()
const knobStateSchema: zod.ZodType = zod.object({
+ /** Last `permission/preset` payload, or null. */
preset: zod.string().nullable(),
- sandbox: zod.union([
- zod.literal('read-only'),
- zod.literal('workspace-write'),
- zod.literal('danger-full-access'),
- ]).nullable(),
- approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(),
+ sandbox: sandboxField,
+ approval: approvalField,
})
/** State for the empty log: every knob at its composition default. */
@@ -139,13 +106,6 @@ export function applyKnobEvent(state: KnobState, event: SessionEvent): KnobState
}
}
-/** Whole-log knob fold (the cold-read parallel of {@link applyKnobEvent}). */
-function foldKnobs(events: readonly SessionEvent[]): KnobState {
- let state = EMPTY_KNOBS
- for (const event of events) state = applyKnobEvent(state, event)
- return state
-}
-
/** User setting resolved when a new session receives its initial permission. */
export interface PermissionSettings {
/** Preset pinned into a newly created session. */
@@ -193,7 +153,7 @@ export class PermissionPresetService extends Service {
defaultPreset: z.string(),
})
- static inject = ['shell', 'approval', 'sessions']
+ static inject = ['shell', 'approval', 'sessions', 'sessionProjections']
private readonly presets: Record
private defaultSettings: () => PermissionSettings
@@ -240,10 +200,6 @@ export class PermissionPresetService extends Service {
this.pinInitialPermission(session)
}
- // The permissions projection unit: fold the three whole-value knob
- // events; view derives the select over the composition defaults this
- // service already owns. The unit child activates only when a projection
- // registry is composed (headless assemblies stay unaffected).
// zod `.optional()` types the key `string | undefined` while the domain
// says `description?: string`; on the JSON wire the two serialize
// identically (absent), so the cast records exactly that
@@ -256,15 +212,13 @@ export class PermissionPresetService extends Service {
})),
currentValue: zod.string().min(1),
}) as unknown as zod.ZodType
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register<'permissions', KnobState>({
- key: 'permissions',
- stateSchema: knobStateSchema,
- init: () => EMPTY_KNOBS,
- apply: applyKnobEvent,
- wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
- stateVersion: 1,
- })
+ ctx.sessionProjections.register({
+ key: 'permissions',
+ stateVersion: 1,
+ stateSchema: knobStateSchema,
+ init: () => EMPTY_KNOBS,
+ apply: applyKnobEvent,
+ wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
})
// The /permission command: the one write path a web client uses (the
@@ -281,7 +235,7 @@ export class PermissionPresetService extends Service {
handler: ({ agent, rawInput }) => {
const name = rawInput.trim()
if (name === '') {
- return { kind: 'success', text: `current preset ${this.current(agent.session.events)} (available: ${this.names.join(', ')})` }
+ return { kind: 'success', text: `current preset ${this.current(agent.session)} (available: ${this.names.join(', ')})` }
}
if (!this.names.includes(name)) {
return { kind: 'error', text: `unknown preset "${name}" (available: ${this.names.join(', ')})` }
@@ -310,15 +264,19 @@ export class PermissionPresetService extends Service {
return this.defaultSettings().defaultPreset
}
+ private knobs(session: Session): KnobState {
+ return this.ctx.sessionProjections.stateOf(session, 'permissions') ?? EMPTY_KNOBS
+ }
+
/**
* Resolve the preset matching the effective knob values. A still-matching
* last selection wins shared-bundle ties; otherwise the first table match
* wins, or {@link CUSTOM_PRESET} when no entry matches.
- * @param events - the session's events in log order.
+ * @param session - the session whose knob state is read.
* @returns the effective preset name, or `custom` when nothing matches.
*/
- current(events: readonly SessionEvent[]): string {
- return this.derive(foldKnobs(events))
+ current(session: Session): string {
+ return this.derive(this.knobs(session))
}
/** Resolve the preset for one folded knob state (the shared mathematics of `current` and the projection unit). */
@@ -395,14 +353,14 @@ export class PermissionPresetService extends Service {
/** Apply one preset with the caller-selected live or initialization policy writer. */
private apply(session: Session, name: string, setApproval: (policy: ApprovalPolicy) => void): void {
const spec = this.resolve(name)
- if (this.current(session.events) !== name) {
+ if (this.current(session) !== name) {
session.append('permission/preset', { preset: name })
}
- const events = session.events
- if (spec.sandbox !== (effectiveSandboxMode(events) ?? this.ctx.shell.sandboxMode)) {
+ const knobs = this.knobs(session)
+ if (spec.sandbox !== (knobs.sandbox ?? this.ctx.shell.sandboxMode)) {
setSandboxMode(session, spec.sandbox)
}
- if (spec.approval !== (effectiveApprovalPolicy(events) ?? this.ctx.approval.config.policy ?? 'ask')) {
+ if (spec.approval !== (knobs.approval ?? this.ctx.approval.config.policy ?? 'ask')) {
setApproval(spec.approval)
}
}
@@ -414,12 +372,12 @@ export class PermissionPresetService extends Service {
* the missing durable facts.
*/
private pinInitialPermission(session: Session): void {
- const events = session.events
- const selected = effectivePermissionPreset(events)
- const sandbox = effectiveSandboxMode(events)
- const approval = effectiveApprovalPolicy(events)
- const seeded = events.some(event => event.type === 'session/end-seed')
- if (selected === undefined && sandbox === undefined && approval === undefined && !seeded) {
+ const state = this.knobs(session)
+ const selected = state.preset
+ const sandbox = state.sandbox
+ const approval = state.approval
+ const seeded = session.events.some(event => event.type === 'session/end-seed')
+ if (selected === null && sandbox === null && approval === null && !seeded) {
const name = this.defaultPreset
const spec = this.resolve(name)
session.append('permission/preset', { preset: name })
@@ -428,19 +386,14 @@ export class PermissionPresetService extends Service {
return
}
- const state: KnobState = {
- preset: selected ?? null,
- sandbox: sandbox ?? null,
- approval: approval ?? null,
- }
const effective = this.derive(state)
- if (selected === undefined && effective !== CUSTOM_PRESET) {
+ if (selected === null && effective !== CUSTOM_PRESET) {
session.append('permission/preset', { preset: effective })
}
- if (sandbox === undefined) {
+ if (sandbox === null) {
setSandboxMode(session, this.ctx.shell.sandboxMode as SandboxMode)
}
- if (approval === undefined) {
+ if (approval === null) {
setApprovalPolicy(session, this.ctx.approval.config.policy ?? 'ask')
}
}
diff --git a/packages/interaction/permission-presets/src/types.ts b/packages/interaction/permission-presets/src/types.ts
index a978e3b5e8..2c9ef57acc 100644
--- a/packages/interaction/permission-presets/src/types.ts
+++ b/packages/interaction/permission-presets/src/types.ts
@@ -2,13 +2,15 @@
* Pure types of the permission domain: the ONE home of the `permissions`
* projection-key declaration plus its payload types, free of this package's
* host-side value imports (cordis, schemastery). Two namespace projections
- * serve it — the package root re-export for host consumers, `./client` (the
- * browser half-entry's re-export) for client aggregates — with zero content
- * duplication.
+ * serve it — the package root re-export for host consumers and `./client` for
+ * client aggregates — with zero content duplication.
*
* @module @deepseek-ai/dsh-permission-presets/types
*/
+import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
+import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+
/** The select-option shape a presentation layer advertises for one preset (or for the derived `custom` state). */
export interface PresetOption {
/** Stable option value: the table key, or `custom`. */
@@ -31,7 +33,21 @@ export interface PermissionSelect {
currentValue: string
}
+/** Latest logged permission overrides. */
+export interface KnobState {
+ /** Last `permission/preset` payload, or null. */
+ preset: string | null
+ /** Last `sandbox/mode` payload, or null. */
+ sandbox: SandboxMode | null
+ /** Last `approval/policy` payload, or null. */
+ approval: ApprovalPolicy | null
+}
+
declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Latest logged permission overrides. */
+ permissions: KnobState
+ }
interface SessionProjectionMap {
/**
* The session's permission select, folded from the three whole-value
diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
index 1f0e8760b8..251a917dde 100644
--- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts
+++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts
@@ -1,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
import PermissionPresetService, {
- CUSTOM_PRESET, effectivePermissionPreset, PERMISSION_SETTINGS_NAMESPACE,
+ CUSTOM_PRESET, PERMISSION_SETTINGS_NAMESPACE,
} from '@deepseek-ai/dsh-permission-presets'
import type { Config } from '@deepseek-ai/dsh-permission-presets'
import { SettingsProvider } from '@deepseek-ai/dsh-settings'
@@ -32,6 +33,7 @@ async function mounted(options: {
} = {}): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
ctx.provide('shell', {
sandboxMode: 'bashDefault' in options ? options.bashDefault : 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
@@ -50,6 +52,7 @@ function freshSession(id: string): Session {
async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefined } = {}): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(MemorySettings)
ctx.provide('shell', {
sandboxMode: 'workspace-write',
@@ -64,16 +67,18 @@ async function mountedStore(options: { approvalDefault?: ApprovalPolicy | undefi
return ctx
}
-describe('effectivePermissionPreset', () => {
- it('folds to the last event, or undefined without one', () => {
+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')
- expect(effectivePermissionPreset(session.events)).toBeUndefined()
+ const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null
+ expect(presetOf()).toBeNull()
session.append('permission/preset', { preset: 'danger-full-access' })
session.append('permission/preset', { preset: 'workspace-write' })
- expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
- // The backward scan steps over non-preset events to the latest selection.
+ expect(presetOf()).toBe('workspace-write')
+ // The knob fold steps over non-preset events to the latest selection.
session.append('sandbox/mode', { mode: 'read-only' })
- expect(effectivePermissionPreset(session.events)).toBe('workspace-write')
+ expect(presetOf()).toBe('workspace-write')
})
})
@@ -88,18 +93,18 @@ describe('PermissionPresetService', () => {
it('current() derives from the effective knobs: composition defaults hit workspace-write, a switch hits its preset', async () => {
const ctx = await mounted()
const session = freshSession('sess-current')
- expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(session)).toBe('workspace-write')
ctx.permissionPresets.set(session, 'danger-full-access')
- expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
+ expect(ctx.permissionPresets.current(session)).toBe('danger-full-access')
})
it('a knob state matching no table entry derives custom — a state, not an error', async () => {
const ctx = await mounted()
const session = freshSession('sess-custom')
session.append('sandbox/mode', { mode: 'read-only' })
- expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET)
+ expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET)
ctx.permissionPresets.set(session, 'danger-full-access')
- expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
+ expect(ctx.permissionPresets.current(session)).toBe('danger-full-access')
expect(() => ctx.permissionPresets.resolve(CUSTOM_PRESET)).toThrow(/unknown preset/)
})
@@ -109,7 +114,7 @@ describe('PermissionPresetService', () => {
config: { defaultPreset: 'workspace-write' },
})
const session = freshSession('sess-defaults-custom')
- expect(ctx.permissionPresets.current(session.events)).toBe(CUSTOM_PRESET)
+ expect(ctx.permissionPresets.current(session)).toBe(CUSTOM_PRESET)
})
it('the fold breaks bundle ties; a stale fold no longer matching falls back to table order', async () => {
@@ -120,10 +125,10 @@ describe('PermissionPresetService', () => {
} } })
const session = freshSession('sess-tie')
ctx.permissionPresets.set(session, 'agentish')
- expect(ctx.permissionPresets.current(session.events)).toBe('agentish')
+ expect(ctx.permissionPresets.current(session)).toBe('agentish')
session.append('approval/policy', { policy: 'never' })
session.append('sandbox/mode', { mode: 'danger-full-access' })
- expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
+ expect(ctx.permissionPresets.current(session)).toBe('danger-full-access')
})
it('set() writes through: one preset event plus both knob events', async () => {
@@ -188,7 +193,7 @@ describe('PermissionPresetService', () => {
const session = freshSession('sess-standin')
ctx.permissionPresets.set(session, 'workspace-write')
expect(session.events).toHaveLength(0)
- expect(ctx.permissionPresets.current(session.events)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(session)).toBe('workspace-write')
})
})
@@ -207,8 +212,8 @@ describe('new-session default', () => {
})
expect(ctx.permissionPresets.defaultPreset).toBe('danger-full-access')
const second = ctx.sessions.create(SessionId('second'))
- expect(ctx.permissionPresets.current(first.events)).toBe('workspace-write')
- expect(ctx.permissionPresets.current(second.events)).toBe('danger-full-access')
+ expect(ctx.permissionPresets.current(first)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(second)).toBe('danger-full-access')
expect(second.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -223,7 +228,7 @@ describe('new-session default', () => {
legacy.append('turn/start', { turn: 1 })
legacy.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const resumed = ctx.sessions.create(SessionId('legacy-resumed'), { seed: legacy.events })
- expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write')
expect(resumed.events.slice(-3).map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -235,7 +240,7 @@ describe('new-session default', () => {
defaultPreset: 'danger-full-access',
})
const resumed = ctx.sessions.create(SessionId('empty-resumed'), { seed: [] })
- expect(ctx.permissionPresets.current(resumed.events)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(resumed)).toBe('workspace-write')
expect(resumed.events.map(event => event.type)).toEqual([
'session/end-seed', 'permission/preset', 'sandbox/mode', 'approval/policy',
])
@@ -244,6 +249,7 @@ describe('new-session default', () => {
it('pins sessions that already exist when the service remounts', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
ctx.provide('shell', {
sandboxMode: 'workspace-write',
resolve() { throw new Error('permission tests do not execute bash') },
@@ -258,7 +264,7 @@ describe('new-session default', () => {
expect(existing.events.map(event => event.type)).toEqual([
'permission/preset', 'sandbox/mode', 'approval/policy',
])
- expect(ctx.permissionPresets.current(existing.events)).toBe('workspace-write')
+ expect(ctx.permissionPresets.current(existing)).toBe('workspace-write')
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
@@ -276,7 +282,7 @@ describe('new-session default', () => {
custom.append('sandbox/mode', { mode: 'read-only' })
custom.append('approval/policy', { policy: 'never' })
const unmatched = ctx.sessions.create(SessionId('custom-resumed'), { seed: custom.events })
- expect(ctx.permissionPresets.current(unmatched.events)).toBe(CUSTOM_PRESET)
+ expect(ctx.permissionPresets.current(unmatched)).toBe(CUSTOM_PRESET)
expect(unmatched.events.at(-1)?.type).toBe('session/end-seed')
})
diff --git a/packages/interaction/permission-presets/tests/projection.spec.ts b/packages/interaction/permission-presets/tests/projection.spec.ts
index f9bba33958..d9fa287b6b 100644
--- a/packages/interaction/permission-presets/tests/projection.spec.ts
+++ b/packages/interaction/permission-presets/tests/projection.spec.ts
@@ -60,10 +60,9 @@ describe('permissions projection unit', () => {
changes.push({ key, value, seq })
})
ctx.permissionPresets.set(session, 'danger-full-access')
- // set() appends preset + sandbox/mode + approval/policy: three knob transitions.
- expect(changes).toHaveLength(3)
- expect(changes.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
- // Unrelated event: same-reference apply, no notification.
+ const permissionChanges = changes.filter(change => change.key === 'permissions')
+ expect(permissionChanges).toHaveLength(3)
+ expect(permissionChanges.at(-1)).toMatchObject({ key: 'permissions', value: { currentValue: 'danger-full-access' } })
session.append('turn/start', { turn: 1 })
expect(changes).toHaveLength(3)
})
@@ -80,7 +79,8 @@ describe('permissions projection unit', () => {
const { ctx, session } = await harness({ withPermission: false })
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
const fiber = await ctx.plugin(PermissionPresetService, {})
- expect(ctx.sessionProjections.snapshot(session).values.permissions).toMatchObject({ currentValue: 'workspace-write' })
+ expect(ctx.sessionProjections.snapshot(session).values.permissions)
+ .toMatchObject({ currentValue: 'workspace-write' })
await fiber.dispose()
expect('permissions' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
@@ -92,7 +92,7 @@ describe('/permission command', () => {
const { agent, inject } = await agentFor(ctx, session)
const execution = await ctx.commands.execute(agent, '/permission danger-full-access', [], new AbortController().signal)
expect(execution?.result).toEqual({ kind: 'success', text: 'preset danger-full-access' })
- expect(ctx.permissionPresets.current(session.events)).toBe('danger-full-access')
+ expect(ctx.permissionPresets.current(session)).toBe('danger-full-access')
expect(inject.mock.calls[0]?.[0]).toMatchObject({
content: [{
type: 'text',
diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json
index 06f72c1e2f..3eb84ad80e 100644
--- a/packages/interaction/user-approval/package.json
+++ b/packages/interaction/user-approval/package.json
@@ -44,10 +44,12 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:^"
+ "@deepseek-ai/schemastery": "workspace:^",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -57,6 +59,8 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^"
}
}
diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts
index b0618f6b3d..51712ecc1c 100644
--- a/packages/interaction/user-approval/src/index.ts
+++ b/packages/interaction/user-approval/src/index.ts
@@ -7,11 +7,13 @@
import { randomUUID } from 'node:crypto'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
+import { z as zod } from 'zod'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/cordis' {
@@ -31,6 +33,33 @@ declare module '@deepseek-ai/cordis' {
}
}
+const approvalPolicyStateSchema = zod.union([
+ zod.literal('ask'),
+ zod.literal('never'),
+]).nullable()
+
+type ApprovalPolicyState = zod.infer
+
+const approvalPendingStateSchema = zod.record(zod.string(), zod.object({
+ callId: zod.string().nullable(),
+ seq: zod.number().int().nonnegative(),
+}))
+
+type ApprovalPendingState = zod.infer
+
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Last logged approval-policy override, or null before one (composition default applies at read time). */
+ approvalPolicy: ApprovalPolicyState
+ /**
+ * Outstanding `approval/asked` records not yet resolved by their
+ * `approval/decided` — the audit-pair pending set (the carrier's
+ * approval/request correlation reads it).
+ */
+ approvalPending: ApprovalPendingState
+ }
+}
+
declare module '@deepseek-ai/dsh-session/types' {
interface SessionEventMap {
/**
@@ -60,7 +89,8 @@ declare module '@deepseek-ai/dsh-session/types' {
* The session's approval policy was switched — log-only, durable,
* replayable, never in the model transcript (the model learns the policy
* from the runtime-context snapshot and live switch notices). The LAST
- * such event is the session's override ({@link effectiveApprovalPolicy}).
+ * such event is the session's override (folded by the approvalPolicy
+ * projection unit).
* `source: 'delegation'` marks an override seeded into a child; an absent
* source is a runtime switch.
*/
@@ -101,38 +131,6 @@ const NEVER_SENTENCE = 'Approval prompts are disabled in this session: actions t
/** Model-facing statement for an interactive policy that may still fail closed. */
const ASK_SENTENCE = 'Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.'
-/**
- * The session's approval-policy override: the last `approval/policy` event in
- * the log, or undefined when the session never switched (callers apply the
- * plugin's configured default). The pure fold — resume needs no catch-up
- * machinery because replaying the log IS the state.
- * @param events - session events in log order (other event types are skipped).
- * @returns the policy of the last switch event, or undefined without one.
- */
-export function effectiveApprovalPolicy(events: readonly SessionEvent[]): ApprovalPolicy | undefined {
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const event = events[index] as SessionEvent
- if (event.type === 'approval/policy') return event.data.policy
- }
- return undefined
-}
-
-/**
- * Whether the log currently sits inside an open turn (a `turn/start` not yet
- * closed by a `turn/end`) — the {@link ApprovalService.request} precondition.
- * The audit pair must be turn-enclosed: the turn is the durable log's
- * commit/replay boundary, so a bare event appended between turns is
- * indistinguishable from a crash tail and silently dropped on reload.
- */
-function hasOpenTurn(events: readonly SessionEvent[]): boolean {
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const type = (events[index] as SessionEvent).type
- if (type === 'turn/start') return true
- if (type === 'turn/end') return false
- }
- return false
-}
-
/**
* Append the sole durable representation of a session policy override. Invalid
* values throw before the log changes; consumers fold the new value on each read.
@@ -194,11 +192,39 @@ export class ApprovalService extends Service {
policy: z.union(['ask', 'never'] as const).default('ask'),
})
+ static inject = ['sessionProjections']
+
constructor(ctx: Context, public config: Config) {
super(ctx, 'approval')
const effective = (agent: Agent): ApprovalPolicy => this.effectivePolicy(agent.session)
+ ctx.sessionProjections.register({
+ key: 'approvalPolicy',
+ stateVersion: 1,
+ stateSchema: approvalPolicyStateSchema,
+ init: () => null,
+ apply: (state, event) => (event.type === 'approval/policy' ? event.data.policy : state),
+ })
+ ctx.sessionProjections.register({
+ key: 'approvalPending',
+ stateVersion: 1,
+ stateSchema: approvalPendingStateSchema,
+ init: () => ({}),
+ apply: (state, event) => {
+ if (event.type === 'approval/asked') {
+ if (state[event.data.id] !== undefined) return state
+ return { ...state, [event.data.id]: { callId: event.data.callId ?? null, seq: event.seq } }
+ }
+ if (event.type === 'approval/decided') {
+ if (state[event.data.id] === undefined) return state
+ const { [event.data.id]: _decided, ...next } = state
+ return next
+ }
+ return state
+ },
+ })
+
// The complete current value travels after retained history, so switching
// policy does not rewrite the stable system-prompt cache prefix.
ctx.inject(['systemPrompt'], (scope: Context) => {
@@ -256,7 +282,7 @@ export class ApprovalService extends Service {
*/
async request(req: ApprovalRequest): Promise {
const session = req.agent.session
- if (!hasOpenTurn(session.events)) {
+ if ((this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) === null) {
throw new Error(
'approval.request() outside an open turn: the approval/asked + approval/decided audit pair '
+ 'must be turn-enclosed (a bare event between turns is crash-tail garbage on reload). '
@@ -276,7 +302,7 @@ export class ApprovalService extends Service {
}
/**
- * The session's effective policy: its own `approval/policy` fold, else the
+ * The session's effective policy: its projected `approval/policy` state, else the
* configured default (the schema already defaulted an omitted policy to
* `'ask'`; the `??` only narrows the optional-input TYPE).
* @param session - the exact accepted session whose policy applies.
@@ -287,12 +313,12 @@ export class ApprovalService extends Service {
}
/**
- * Read the session override without applying the configured default.
+ * Read the projected session override without applying the configured default.
* @param session - session whose log supplies the override.
* @returns the last logged policy, or `undefined` without one.
*/
overrideOf(session: Session): ApprovalPolicy | undefined {
- return effectiveApprovalPolicy(session.events)
+ return this.ctx.sessionProjections.stateOf(session, 'approvalPolicy') ?? undefined
}
/**
diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts
index 3271ecc7a5..906551f229 100644
--- a/packages/interaction/user-approval/tests/approval.spec.ts
+++ b/packages/interaction/user-approval/tests/approval.spec.ts
@@ -6,8 +6,14 @@ import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope'
import type { Scope } from '@deepseek-ai/dsh-scope'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
-import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+import ApprovalService, { ApprovalOutcome, ApprovalRequest, ApprovalRequestId, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+
+function registerTurnBoundary(ctx: Context): void {
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+}
/**
* A minimal Agent stand-in — the service only reaches `agent.session.append`
@@ -15,7 +21,10 @@ import ApprovalService, { ApprovalOutcome, ApprovalRequest, effectiveApprovalPol
* turn-enclosure precondition); pass `seed` to stage idle/closed logs.
* Returns the recorded audit appends alongside the fake.
*/
-function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { type: 'user/message' }]): { agent: Agent; appended: Array<{ type: string; data: Record }> } {
+function fakeAgent(seed: Array<{ type: string; data?: Record; seq?: number }> = [
+ { type: 'turn/start', seq: 0, data: { turn: 1 } },
+ { type: 'user/message', seq: 1, data: {} },
+]): { agent: Agent; appended: Array<{ type: string; data: Record }> } {
const appended: Array<{ type: string; data: Record }> = []
const agent = {
session: {
@@ -31,6 +40,8 @@ function fakeAgent(seed: Array<{ type: string }> = [{ type: 'turn/start' }, { ty
async function mounted(): Promise {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
return ctx
}
@@ -50,7 +61,10 @@ describe('ApprovalService.request', () => {
it('throws between turns — a closed turn does not satisfy the enclosure precondition', async () => {
const ctx = await mounted()
- const { agent, appended } = fakeAgent([{ type: 'turn/start' }, { type: 'turn/end' }])
+ const { agent, appended } = fakeAgent([
+ { type: 'turn/start', seq: 0, data: { turn: 1 } },
+ { type: 'turn/end', seq: 1, data: { turn: 1, reason: { kind: 'completed' } } },
+ ])
await expect(ctx.approval.request(requestOf(agent))).rejects.toThrow(/outside an open turn/)
expect(appended).toHaveLength(0)
@@ -116,6 +130,8 @@ describe('ApprovalService.request', () => {
it('contains an approval/asked observer throw after append and still completes the pair', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('asked-observer-throw'))
session.append('turn/start', { turn: 1 })
@@ -139,6 +155,8 @@ describe('ApprovalService.request', () => {
it('contains an approval/decided observer throw after append and still resolves', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const session = ctx.sessions.create(SessionId('decided-observer-throw'))
session.append('turn/start', { turn: 1 })
@@ -164,7 +182,7 @@ describe('ApprovalService.request', () => {
const failure = new Error('append failed before log growth')
const agent = {
session: {
- events: [{ type: 'turn/start' }],
+ events: [{ type: 'turn/start', seq: 0, data: { turn: 1 } }],
append: () => { throw failure },
},
} as unknown as Agent
@@ -364,15 +382,45 @@ describe('approval policy (the approval/policy fold)', () => {
return { agent, session }
}
- it('folds to the last event, or undefined without one', () => {
+ it('folds to the last event, or undefined without one', async () => {
+ const ctx = await mounted()
const { session } = sessionAgent('sess-fold')
- expect(effectiveApprovalPolicy(session.events)).toBeUndefined()
+ expect(ctx.approval.overrideOf(session)).toBeUndefined()
setApprovalPolicy(session, 'never')
setApprovalPolicy(session, 'ask')
- expect(effectiveApprovalPolicy(session.events)).toBe('ask')
+ expect(ctx.approval.overrideOf(session)).toBe('ask')
expect(session.events.at(-1)).toMatchObject({ type: 'approval/policy', data: { policy: 'ask' } })
})
+ it('folds the pending audit pair without double-counting duplicates', async () => {
+ const ctx = await mounted()
+ const { session } = sessionAgent('sess-pending')
+ const asked = session.append('approval/asked', {
+ id: ApprovalRequestId('pending-1'),
+ callId: CallId('call-1'),
+ toolName: 'echo',
+ })
+ session.append('approval/asked', {
+ id: ApprovalRequestId('pending-1'),
+ callId: CallId('call-1'),
+ toolName: 'echo',
+ })
+ const pending = ctx.sessionProjections.snapshot(session).values.approvalPending ?? {}
+ expect(pending).toEqual({
+ 'pending-1': { callId: 'call-1', seq: asked.seq },
+ })
+ })
+
+ it('ignores a decided event without a pending asked record', async () => {
+ const ctx = await mounted()
+ const { session } = sessionAgent('sess-unknown-decide')
+ session.append('approval/decided', {
+ id: ApprovalRequestId('never-asked'),
+ outcome: 'rejected',
+ })
+ expect(ctx.sessionProjections.snapshot(session).values.approvalPending).toEqual({})
+ })
+
it('rejects a policy outside the closed vocabulary before appending', () => {
const append = vi.fn()
const session = { append } as unknown as Session
@@ -386,6 +434,8 @@ describe('approval policy (the approval/policy fold)', () => {
// Direct construction bypasses the plugin schema (the SystemPrompt-test
// precedent for covering a defaulted Config field's type-narrowing ??).
const ctx = new Context()
+ new SessionProjectionRegistry(ctx)
+ registerTurnBoundary(ctx)
const service = new ApprovalService(ctx, {})
const { agent } = sessionAgent('sess-bare-config')
ctx.on('approval/request', () => Promise.resolve('allowed-once'))
@@ -394,6 +444,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('contains an answerer that throws SYNCHRONOUSLY as unavailable', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const { agent } = sessionAgent('sess-syncthrow')
ctx.on('approval/request', () => { throw new Error('sync bug') })
@@ -402,6 +454,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('a never config rejects deterministically without consulting any answerer', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()
ctx.on('approval/request', (_req, next) => { consulted(); return next() })
@@ -416,6 +470,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('the gate decides FIRST even against an answerer registered before the service (prepend)', async () => {
const ctx = new Context()
ctx.on('approval/request', () => Promise.resolve('allowed-once'))
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService, { policy: 'never' })
const { agent } = sessionAgent('sess-gate-2')
await expect(ctx.approval.request({ agent, toolName: 'bash' })).resolves.toBe('rejected')
@@ -426,6 +482,8 @@ describe('approval policy (the approval/policy fold)', () => {
// service could register — which is exactly why the 'never' decision lives inside request()
// instead. This eager grant would bypass a listener-based gate and therefore must never run.
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService, { policy: 'never' })
const consulted = vi.fn()
ctx.on('approval/request', () => { consulted(); return Promise.resolve('allowed-once') }, { prepend: true })
@@ -437,6 +495,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('a session override outranks the configured default, in both directions', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService, { policy: 'never' })
ctx.on('approval/request', () => Promise.resolve('allowed-once'))
const { agent, session } = sessionAgent('sess-gate-3')
@@ -450,6 +510,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('queues a live policy switch for the next model step', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-policy-notice')
const inject = vi.fn()
@@ -458,7 +520,7 @@ describe('approval policy (the approval/policy fold)', () => {
ctx.approval.setPolicy(liveAgent, 'never')
ctx.approval.setPolicy(liveAgent, 'never')
- expect(effectiveApprovalPolicy(session.events)).toBe('never')
+ expect(ctx.approval.overrideOf(session)).toBe('never')
expect(inject).toHaveBeenCalledOnce()
expect(inject.mock.calls[0]?.[0]).toMatchObject({
content: [{
@@ -472,6 +534,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('contributes the complete current ask or never policy as cache-safe context', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const askAgent = sessionAgent('sess-sect-ask').agent
const { agent: neverAgent, session } = sessionAgent('sess-sect-never')
@@ -487,6 +551,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('reflects the latest durable switch in cache-safe context and stays byte-stable while unchanged', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(ApprovalService)
const { agent, session } = sessionAgent('sess-context-switch')
const contextFor = async () =>
@@ -503,6 +569,8 @@ describe('approval policy (the approval/policy fold)', () => {
it('disposes the runtime-context contribution with the service', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
const fiber = await ctx.plugin(ApprovalService)
const { agent } = sessionAgent('sess-hmr-service-live')
const contextFor = async () =>
diff --git a/packages/interaction/user-approval/tsconfig.json b/packages/interaction/user-approval/tsconfig.json
index 2c8d26e1f6..7adfb948c7 100644
--- a/packages/interaction/user-approval/tsconfig.json
+++ b/packages/interaction/user-approval/tsconfig.json
@@ -37,6 +37,9 @@
},
{
"path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../session/session-projection"
}
]
}
diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json
index 44386ce78b..27fcaf37ae 100644
--- a/packages/llm/llm-retry/package.json
+++ b/packages/llm/llm-retry/package.json
@@ -42,10 +42,12 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:^"
+ "@deepseek-ai/schemastery": "workspace:^",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -64,6 +66,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts
index 45db304059..95568bab0a 100644
--- a/packages/llm/llm-retry/src/index.ts
+++ b/packages/llm/llm-retry/src/index.ts
@@ -8,9 +8,10 @@
import { randomUUID } from 'node:crypto'
import type { Context, Events } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
+import { z as zod } from 'zod'
import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent'
import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
-import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-projection'
import { RetryId } from './brand.ts'
import type { LlmRetryEventData } from './types.ts'
@@ -18,7 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts'
export { RetryId } from './brand.ts'
export const name = 'llm-retry'
-export const inject = ['agents']
+export const inject = ['agents', 'sessionProjections']
/** This policy executor has no config; providers own `retryPolicy`. */
export type Config = Readonly>
@@ -75,6 +76,10 @@ function retryPolicyKey(policy: ResolvedRetryPolicy): string {
])
}
+function retryStateKey(provider: string, policyKey: string): string {
+ return JSON.stringify([provider, policyKey])
+}
+
function cancellableDelay(delayMs: number, signal: AbortSignal): Promise {
if (signal.aborted) return Promise.resolve(false)
return new Promise((resolve) => {
@@ -96,7 +101,39 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise
+
+const llmRetryStateSchema: zod.ZodType = zod.record(zod.string(), zod.object({
+ retry: zod.number().int().nonnegative(),
+ retryId: zod.string(),
+})) as unknown as zod.ZodType
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Retry state for the current step by provider and policy. */
+ llmRetry: LlmRetryState
+ }
+}
+
export function apply(ctx: Context, config: Config = {}, internals: RetryInternals = {}): void {
+ ctx.sessionProjections.register({
+ key: 'llmRetry',
+ stateVersion: 2,
+ stateSchema: llmRetryStateSchema,
+ init: () => ({}),
+ apply: (state, event) => {
+ if (event.type === 'step/start' || event.type === 'turn/end') return Object.keys(state).length === 0 ? state : {}
+ if (event.type !== 'llm/retry') return state
+ const key = retryStateKey(event.data.provider, event.data.policyKey)
+ const entry = state[key]
+ if (entry?.retry === event.data.retry && entry.retryId === event.data.retryId) return state
+ return { ...state, [key]: { retry: event.data.retry, retryId: event.data.retryId } }
+ },
+ })
validateConfig(config)
const random = internals.random ?? Math.random
const lifetime = new AbortController()
@@ -179,17 +216,12 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna
}
const policyKey = retryPolicyKey(policy)
- const priorPolicyRetry = agent.session.events.findLast((event): event is SessionEvent<'llm/retry'> =>
- event.type === 'llm/retry'
- && event.data.turn === turn
- && event.data.step === step
- && event.data.provider === provider
- && event.data.policyKey === policyKey,
- )
- const previousRetry = priorPolicyRetry?.data.retry ?? 0
+ const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry')
+ const previous = retryState?.[retryStateKey(provider, policyKey)]
+ const previousRetry = previous?.retry ?? 0
if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next()
const retry = previousRetry + 1
- const retryId = priorPolicyRetry?.data.retryId ?? RetryId(randomUUID())
+ const retryId = previous?.retryId ?? RetryId(randomUUID())
let delayMs: number
if (failure.providerRetryAfterMs !== undefined
&& Number.isFinite(failure.providerRetryAfterMs)
diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts
index 47d6d2a812..0c6c9c6b6b 100644
--- a/packages/llm/llm-retry/tests/invariant.spec.ts
+++ b/packages/llm/llm-retry/tests/invariant.spec.ts
@@ -4,7 +4,10 @@ import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import AgentRegistry from '@deepseek-ai/dsh-agent'
import * as RetryInvariant from '@deepseek-ai/dsh-llm-retry/invariant'
+import * as retry from '../src/index.ts'
import { RetryId } from '@deepseek-ai/dsh-llm-retry'
import { providerForOpenStep } from '../src/history.ts'
@@ -58,6 +61,28 @@ const always = {
failure,
}
+describe('llm-retry projection fold', () => {
+ it('keeps one retry count per dispatch key across duplicate records', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(retry)
+ const session = ctx.sessions.create(SessionId('retry-dup'))
+ session.append('llm/retry', { turn: 1, step: 1, ...normal })
+ const first = ctx.sessionProjections.stateOf(session, 'llmRetry')
+ session.append('llm/retry', { turn: 1, step: 1, ...normal })
+ const second = ctx.sessionProjections.stateOf(session, 'llmRetry')
+ expect(second).toBe(first)
+ expect(second).toEqual({
+ '["mock","normal-policy"]': {
+ retry: 1,
+ retryId: 'normal-retry-chain',
+ },
+ })
+ })
+})
+
describe('llm-retry invariants', () => {
it('has no provider without the requested open step or a route marker', () => {
expect(providerForOpenStep([], 1, 1)).toBeUndefined()
diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts
index 367e8f46f4..b4bef4c239 100644
--- a/packages/llm/llm-retry/tests/loader-composition.spec.ts
+++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts
@@ -11,6 +11,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmRuntime, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
import * as retry from '../src/index.ts'
@@ -60,6 +61,7 @@ async function loadYaml(lines: readonly string[]): Promise {
const modules = new Map([
['@deepseek-ai/dsh-llm', LlmRuntime],
['@deepseek-ai/dsh-session', SessionStore],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-agent', AgentRegistry],
@@ -89,6 +91,7 @@ describe('real Loader composition', () => {
const loaded = await loadYaml([
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-agent'",
diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts
index c2643a6d45..600700d9e3 100644
--- a/packages/llm/llm-retry/tests/retry.spec.ts
+++ b/packages/llm/llm-retry/tests/retry.spec.ts
@@ -12,6 +12,7 @@ import type {
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionEvent, SessionEventMap } from '@deepseek-ai/dsh-session'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -107,6 +108,7 @@ async function harness(
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
@@ -1025,14 +1027,20 @@ describe('provider-routed retry policy', () => {
it('rejects retry policy configured on the executor instead of a provider', () => {
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(new Context(), { retryPolicy: { mode: 'always' } } as unknown as retry.Config)
+ retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config)
}).toThrow(/retryPolicy belongs under each provider/)
})
it('rejects unknown executor config', () => {
+ const ctx = new Context()
+ new SessionProjectionRegistry(ctx)
expect(() => {
- retry.apply(new Context(), { retryPolciy: {} } as unknown as retry.Config)
+ retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config)
}).toThrow(/unknown key "retryPolciy"/)
})
})
diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
index fb830f9868..7591f8fdf2 100644
--- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts
+++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts
@@ -10,6 +10,7 @@ import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as Retry from '../src/index.ts'
let context: Context | undefined
@@ -37,6 +38,7 @@ async function harness(
vi.stubEnv('DEEPSEEK_API_KEY', 'mock-key')
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(LlmDeepSeek, {
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
diff --git a/packages/llm/token-meter/README.i18n.yaml b/packages/llm/token-meter/README.i18n.yaml
index 44db6530cd..156c101f90 100644
--- a/packages/llm/token-meter/README.i18n.yaml
+++ b/packages/llm/token-meter/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/token-meter/README.md
-README.md: a2deab11a31285ba598b8864d3a734ecf7c56620
-README.zh.md: e0c3a8aa123ca0acc374277cd92b415e0b0b3cb5
+README.md: 26bf9d4bc89b5ea1be72bb7d51aa6e6859bfd075
+README.zh.md: f018d22cee94d631bdfcdc26a3aa328e5d91152b
diff --git a/packages/llm/token-meter/README.md b/packages/llm/token-meter/README.md
index a2deab11a3..26bf9d4bc8 100644
--- a/packages/llm/token-meter/README.md
+++ b/packages/llm/token-meter/README.md
@@ -23,7 +23,7 @@ Usage accounting sums disjoint input, cache-read, cache-write, and output bucket
## Session projections
-When the composition provides `ctx.sessionProjections`, token-meter registers three units through an optional child fiber.
+Token-meter requires `ctx.sessionProjections` and registers three units.
`tokenUsage` carries the complete durable log's `uncachedInputTokens`, `outputTokens`, `cacheReadTokens`, and `cacheWriteTokens`. Usage chunks are counted even when a request later fails; a final assistant-message usage for the same `(turn, step)` replaces that sample instead of double-counting it. Reasoning remains an output subdivision. The single last-sample slot relies on a session-log ordering property: once a later step reports usage, a legal log never reports usage for an earlier step again.
diff --git a/packages/llm/token-meter/README.zh.md b/packages/llm/token-meter/README.zh.md
index e0c3a8aa12..f018d22cee 100644
--- a/packages/llm/token-meter/README.zh.md
+++ b/packages/llm/token-meter/README.zh.md
@@ -23,7 +23,7 @@ fold 跟踪完整请求标头快照、步骤边界、表层追加与替换、成
## 会话投影
-当组合提供 `ctx.sessionProjections` 时,token-meter 会通过一个可选子 fiber 注册三个单元。
+Token-meter 要求组合提供 `ctx.sessionProjections`,并注册三个单元。
`tokenUsage` 携带完整持久日志中的 `uncachedInputTokens`、`outputTokens`、`cacheReadTokens` 和 `cacheWriteTokens`。即使请求随后失败,用量分片仍会计入;同一 `(turn, step)` 的最终 assistant 消息用量会替换该样本,而不是重复计数。推理仍是输出的一个细分项。只保留单个最新样本,依赖的是会话日志的一条顺序性质:一旦某个更晚的步骤报告了用量,合法日志就绝不会再为更早的步骤报告用量。
diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts
index 2fa53f78f1..47df376a75 100644
--- a/packages/llm/token-meter/src/index.ts
+++ b/packages/llm/token-meter/src/index.ts
@@ -10,7 +10,6 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { canonicalHeader, headerEquals, isSurfaceEvent } from '@deepseek-ai/dsh-session'
-// Type-only: resolves the optional projection registry Context declaration.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {
TokenMeasurement,
@@ -24,6 +23,11 @@ import { estimateContent, estimateHeader, estimateMessage, ROLE_OVERHEAD } from
import { foldSurfaceTokens } from './surface-fold.ts'
export type * from './types.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.
+export type * from './usage-projection.ts'
+export type * from './breakdown-projection.ts'
interface MeasurementAnchor {
readonly header: EpochHeader | undefined
@@ -76,19 +80,17 @@ export class TokenMeter extends Service {
// the public type excludes settings while validateConfigKeys rejects them.
static Config: z = z.object({}) as unknown as z
+ static inject = ['sessionProjections']
+
private readonly states = new WeakMap()
constructor(ctx: Context, config: TokenMeterConfig = {}) {
super(ctx, 'tokenMeter')
validateConfigKeys(config)
- // Projection registration is an optional child: compositions without the
- // generic registry keep the meter's standalone read shape.
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register(tokenUsageProjectionDefinition)
- projectionCtx.sessionProjections.register(contextPressureProjectionDefinition)
- projectionCtx.sessionProjections.register(contextBreakdownProjectionDefinition)
- })
+ ctx.sessionProjections.register(tokenUsageProjectionDefinition)
+ ctx.sessionProjections.register(contextPressureProjectionDefinition)
+ 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/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
index 56ba67ee80..d72ba7f1f6 100644
--- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts
+++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts
@@ -8,7 +8,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { ContextPressureProjection, TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compaction'
-import type {} from '../src/usage-projection.ts'
const ZERO: TokenUsageProjection = {
uncachedInputTokens: 0,
diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml
index c12c5c248f..4d58cd9c94 100644
--- a/packages/plan/plan-mode/README.i18n.yaml
+++ b/packages/plan/plan-mode/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
-README.md: 3eabe2cb3f04b434b7f908f7beca869f1022a59e
-README.zh.md: f7d6a1f8e9f5ba95f8aad9457f3dde5fc415fdcf
+README.md: 76575d603279c699112feeac82344a504af05a87
+README.zh.md: deada84a896edb0f6a0e597d25cead0f347bf543
diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md
index 3eabe2cb3f..76575d6032 100644
--- a/packages/plan/plan-mode/README.md
+++ b/packages/plan/plan-mode/README.md
@@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
## Durable state
-`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
+`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. The required `plan` projection unit folds committed mode, command settlement, and the mode at the latest request header, so resume, fork, and compaction recover the state directly from the session log.
`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
@@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may
## Session projection
-When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection. Every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. This remains a pure replay quantity, so host restarts, other tabs, and cold reads recover it from the log alone, and a rejected `/plan off` with images cannot leave a pending exit. The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
+This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `plan` projection unit. A `command/run` record named `plan` with recorded `args` starts a candidate target (`off` → inactive, anything else → active); its paired `command/done` retains a successful selection and drops an error; `plan/mode` commits the logged state and clears the retained selection; `request/header` records which mode assembled the latest request. Every other event returns the same state reference. Host logic reads the full state through `stateOf()`. The client `view` remains `{ active, pending }`, where `pending` is true only while an unsettled or successful selection differs from the logged state. The key merges into `SessionProjectionStateMap` for host state and `SessionProjectionMap` for the client view; carriers serve the latter on the history tail page and the `session/projection` push frame.
## Configuration
diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md
index f7d6a1f8e9..deada84a89 100644
--- a/packages/plan/plan-mode/README.zh.md
+++ b/packages/plan/plan-mode/README.zh.md
@@ -6,7 +6,7 @@
## 持久状态
-`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
+`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。必需的 `plan` 投影单元折叠已提交模式、命令结算结果和最近一次请求头对应的模式,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复该状态。
`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个提示词之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
@@ -22,7 +22,7 @@ Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接
## 会话投影
-当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择。其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该值仍完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它,被拒绝的带图 `/plan off` 也不会留下待退出状态。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
+本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `plan` 投影单元。名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会开始一个候选目标(`off` → 未激活,其余 → 激活);与它配对的 `command/done` 保留成功选择并丢弃错误选择;`plan/mode` 提交已记录状态并清除已保留的选择;`request/header` 记录最近一次请求由哪种模式组装。其他任何事件都返回同一个状态引用。host 逻辑通过 `stateOf()` 读取完整状态。客户端 `view` 仍为 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true。该 key 分别合并到 host 状态的 `SessionProjectionStateMap` 与客户端视图的 `SessionProjectionMap`;载体通过历史尾页和 `session/projection` 推送帧提供后者。
## 配置
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index b7b94bf6cf..676baef697 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -6,8 +6,8 @@
* policy enforce restrictions independently and do not read or write plan
* state.
*
- * The state in force is folded from the session log (`plan/mode`, last one
- * wins), so resume and fork restore it without a live mirror. User selections
+ * The required `plan` projection folds the session log, so resume and fork
+ * restore the state without rescanning it in each consumer. User selections
* remain pending until the next accepted in-turn pre-step. The service includes
* the selected state in the proposed step assembly, then appends `plan/mode`
* from `agent/pre-step` only when the step is accepted. Same-step request
@@ -28,19 +28,14 @@ import { z as zod } from 'zod'
import type { ZodType } from 'zod'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
+import type { Session, UserMessage } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { UserQuestionError } from '@deepseek-ai/dsh-user-questions'
-// Type-only edge: resolves `ctx.commands` for the optional command child.
import type {} from '@deepseek-ai/dsh-commands'
-// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
-import type { PlanProjection } from './types.ts'
-// The `plan` projection-key declaration lives in src/types.ts (its one home);
-// this re-export projects the type face onto the package root AND keeps the
-// module edge in the emitted index.d.ts, so aggregate programs consuming the
-// declarations still receive the SessionProjectionMap merge.
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type { PlanProjection, PlanUnitState } from './types.ts'
export type * from './types.ts'
declare module '@deepseek-ai/dsh-session/types' {
@@ -48,7 +43,7 @@ declare module '@deepseek-ai/dsh-session/types' {
/**
* Whether plan mode is in force from this point on: log-only, non-surface,
* whole-value replace. The last `plan/mode` wins; a log with none folds to
- * inactive through {@link foldPlanMode}.
+ * inactive through the projection unit's fold.
*/
'plan/mode': { active: boolean }
}
@@ -118,49 +113,11 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
return { section }
}
-/**
- * Whether plan mode is active after the first `end` events. The last
- * `plan/mode` wins; a prefix with none is inactive.
- *
- * @param events The session log or any prefix of it.
- * @param end Fold `events[0, end)`; defaults to the whole log.
- * @returns Whether plan mode is active.
- */
-export function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
- let active = false
- let index = 0
- for (const event of events) {
- if (index >= end) break
- index++
- if (event.type === 'plan/mode') active = event.data.active
- }
- return active
-}
-
-/**
- * Projection unit state: the logged mode, the latest successful `/plan`
- * selection not yet resolved by a `plan/mode` commit, and an execution whose
- * paired `command/done` has not settled. Plain JSON (persisted-cache
- * precondition).
- */
-interface PlanUnitState {
- active: boolean
- /** The selection's target mode; null when no selection is outstanding. */
- wanted: boolean | null
- /** The latest plan command awaiting its paired settlement. */
- running: { commandId: string; wanted: boolean } | null
-}
-
-declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionStateMap {
- plan: PlanUnitState
- }
-}
-
-const planUnitStateSchema: ZodType = zod.object({
+const planUnitStateSchema = zod.object({
active: zod.boolean(),
wanted: zod.boolean().nullable(),
running: zod.object({ commandId: zod.string(), wanted: zod.boolean() }).nullable(),
+ activeAtLastHeader: zod.boolean().nullable(),
})
/** Wire payload schema of the `plan` projection. */
@@ -169,35 +126,48 @@ const planProjectionSchema: ZodType = zod.object({
pending: zod.boolean(),
})
-/** Whether the log holds an opened turn without its closing `turn/end`. */
-function hasOpenTurn(events: readonly SessionEvent[]): boolean {
- let open = false
- for (const event of events) {
- if (event.type === 'turn/start') open = true
- else if (event.type === 'turn/end') open = false
- }
- return open
-}
-
-/** Plan state at the last logged request header, or `undefined` before the first header. */
-function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
- let lastHeader = -1
- let index = 0
- for (const event of events) {
- if (event.type === 'request/header') lastHeader = index
- index++
- }
- if (lastHeader < 0) return undefined
- return foldPlanMode(events, lastHeader + 1)
-}
+/** Projection of logged plan selections and committed mode. */
+export const planProjectionDefinition = {
+ key: 'plan',
+ stateVersion: 3,
+ stateSchema: planUnitStateSchema,
+ init: () => ({ active: false, wanted: null, running: null, activeAtLastHeader: null }),
+ apply: (state, event) => {
+ if (event.type === 'command/run' && event.data.name === 'plan') {
+ if (event.data.args === undefined) return state
+ const wanted = event.data.args.trim() !== 'off'
+ return { ...state, running: { commandId: event.data.commandId, wanted } }
+ }
+ if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {
+ const wanted = event.data.kind === 'success' && state.running.wanted !== state.active
+ ? state.running.wanted
+ : null
+ return { ...state, wanted, running: null }
+ }
+ if (event.type === 'plan/mode') {
+ return { ...state, active: event.data.active, wanted: null }
+ }
+ if (event.type === 'request/header') {
+ return { ...state, activeAtLastHeader: state.active }
+ }
+ return state
+ },
+ wire: {
+ viewSchema: planProjectionSchema,
+ view: (state) => {
+ const wanted = state.running?.wanted ?? state.wanted
+ return { active: state.active, pending: wanted !== null && wanted !== state.active }
+ },
+ },
+} satisfies ProjectionDefinition<'plan', PlanUnitState>
/**
* `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
- * UIs observe committed flips through `session/event`; there is no live mirror.
+ * Client carriers expose the projection's cropped `{ active, pending }` view.
*/
export class PlanModeController extends Service {
- static inject = ['tools', 'systemPrompt']
+ static inject = ['tools', 'systemPrompt', 'sessionProjections']
/** Validated deployment-owned guidance. */
private readonly section: string
@@ -243,50 +213,11 @@ export class PlanModeController extends Service {
text: (context) => {
if (context.agent === undefined) return ''
const pending = this.pendingIntents.get(context.agent.session)
- return (pending?.active ?? foldPlanMode(context.agent.session.events)) ? this.section : ''
+ return (pending?.active ?? this.loggedActive(context.agent.session)) ? this.section : ''
},
})
- // The plan projection unit (session-projection RFC): a pure event fold
- // serving clients the whole {active, pending} value. `command/run`
- // records the user's logged /plan selection, its paired `command/done`
- // keeps only successful selections, and `plan/mode` records that
- // selection and clears it. Pending is thereby a pure
- // replay quantity: host restarts, other tabs, and cold reads all recover
- // it from the log alone. The unit child activates only when a projection
- // registry is composed (headless assemblies stay unaffected).
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register<'plan', PlanUnitState>({
- key: 'plan',
- stateSchema: planUnitStateSchema,
- init: () => ({ active: false, wanted: null, running: null }),
- apply: (state, event) => {
- if (event.type === 'command/run' && event.data.name === 'plan') {
- if (event.data.args === undefined) return state
- const wanted = event.data.args.trim() !== 'off'
- return { ...state, running: { commandId: event.data.commandId, wanted } }
- }
- if (event.type === 'command/done' && event.data.commandId === state.running?.commandId) {
- const wanted = event.data.kind === 'success' && state.running.wanted !== state.active
- ? state.running.wanted
- : null
- return { ...state, wanted, running: null }
- }
- if (event.type === 'plan/mode') {
- return { ...state, active: event.data.active, wanted: null }
- }
- return state
- },
- wire: {
- viewSchema: planProjectionSchema,
- view: (state) => {
- const wanted = state.running?.wanted ?? state.wanted
- return { active: state.active, pending: wanted !== null && wanted !== state.active }
- },
- },
- stateVersion: 2,
- })
- })
+ ctx.sessionProjections.register(planProjectionDefinition)
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
@@ -311,7 +242,7 @@ export class PlanModeController extends Service {
// Repeat the queued wording while an exit still awaits the
// next accepted pre-step; only a truly inactive session reads
// idempotent.
- return foldPlanMode(agent.session.events)
+ return this.loggedActive(agent.session)
? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
: { kind: 'success', text: 'Plan mode is already inactive.' }
}
@@ -355,7 +286,7 @@ export class PlanModeController extends Service {
execute: async (args, exec) => {
const agent = exec.agent
if (agent === undefined) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`)
- if (!foldPlanMode(agent.session.events)) {
+ if (!this.loggedActive(agent.session)) {
throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`)
}
if (!/^#\s+\S/.test(args.plan.trim())) {
@@ -427,6 +358,19 @@ export class PlanModeController extends Service {
}))
}
+ private loggedActive(session: Session): boolean {
+ /* v8 ignore next -- plan-mode registers its own plan unit, so the key is always present */
+ return this.ctx.sessionProjections.stateOf(session, 'plan')?.active ?? false
+ }
+
+ private hasOpenTurn(session: Session): boolean {
+ return (this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.openTurn ?? null) !== null
+ }
+
+ private loggedActiveAtLastHeader(session: Session): boolean | undefined {
+ return this.ctx.sessionProjections.stateOf(session, 'plan')?.activeAtLastHeader ?? undefined
+ }
+
/**
* Read the logged plan state and any selected state awaiting the next
* accepted in-turn pre-step.
@@ -435,7 +379,7 @@ export class PlanModeController extends Service {
* @returns Current logged state plus a pending selection, when present.
*/
get(agent: Agent): { active: boolean; pending?: boolean } {
- const active = foldPlanMode(agent.session.events)
+ const active = this.loggedActive(agent.session)
const pending = this.pendingIntents.get(agent.session)
return pending === undefined ? { active } : { active, pending: pending.active }
}
@@ -459,15 +403,15 @@ export class PlanModeController extends Service {
set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {
const session = agent.session
const pending = this.pendingIntents.get(session)
- const target = pending?.active ?? foldPlanMode(session.events)
+ const target = pending?.active ?? this.loggedActive(session)
if (active === target) return 'noop'
- if (hasOpenTurn(session.events)) {
+ if (this.hasOpenTurn(session)) {
this.pendingIntents.set(session, { active, narrate: true })
- return foldPlanMode(session.events) === active ? 'cancelled' : 'queued'
+ return this.loggedActive(session) === active ? 'cancelled' : 'queued'
}
// No open turn: commit now. Delete only after append succeeds so a
// failed durable write leaves the selection retryable, not dropped.
- if (active === foldPlanMode(session.events)) {
+ if (active === this.loggedActive(session)) {
this.pendingIntents.delete(session)
return 'cancelled'
}
@@ -483,7 +427,7 @@ export class PlanModeController extends Service {
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
const target = pending.active
- if (target === foldPlanMode(session.events)) {
+ if (target === this.loggedActive(session)) {
this.pendingIntents.delete(session)
return
}
@@ -495,7 +439,7 @@ export class PlanModeController extends Service {
/** Build a user-switch notice when the last logged header described the other mode. */
private narration(session: Session, target: boolean): UserMessage | undefined {
- const told = planModeAtLastHeader(session.events)
+ const told = this.loggedActiveAtLastHeader(session)
if (told === undefined || told === target) return
const text = target
? 'The user switched this session to plan mode.'
diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts
index 8efd32f22a..ffb19aee1e 100644
--- a/packages/plan/plan-mode/src/types.ts
+++ b/packages/plan/plan-mode/src/types.ts
@@ -1,9 +1,9 @@
/**
* Pure types of the plan domain: the ONE home of the `plan` projection-key
- * declaration, free of this package's host-side value imports (cordis
- * service, dsh-tools, dsh-agent). Two namespace projections serve it —
- * `./types` for host consumers, `./client` for client aggregates — with zero
- * content duplication.
+ * declaration, free of this package's host-side value imports (cordis,
+ * dsh-tools, dsh-agent). Two namespace projections serve it — `./types` for
+ * host consumers and `./client` for client aggregates — with zero content
+ * duplication.
*
* @module @deepseek-ai/dsh-plan-mode/types
*/
@@ -21,7 +21,23 @@ export interface PlanProjection {
pending: boolean
}
+/** Host state used to derive {@link PlanProjection}. */
+export interface PlanUnitState {
+ /** Logged plan mode. */
+ active: boolean
+ /** The selection's target mode; null when no selection is outstanding. */
+ wanted: boolean | null
+ /** The latest plan command awaiting its paired settlement. */
+ running: { commandId: string; wanted: boolean } | null
+ /** Active state recorded by the latest `request/header`, or null. */
+ activeAtLastHeader: boolean | null
+}
+
declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Host plan-mode fold state. */
+ plan: PlanUnitState
+ }
interface SessionProjectionMap {
/** Plan collaboration state folded from the plan command lifecycle and `plan/mode` events. */
plan: PlanProjection
diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts
index 77efc3beb3..aa43e9ed1c 100644
--- a/packages/plan/plan-mode/tests/integration.spec.ts
+++ b/packages/plan/plan-mode/tests/integration.spec.ts
@@ -6,7 +6,9 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
-import PlanModeController, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import PlanModeController, { planProjectionDefinition } from '@deepseek-ai/dsh-plan-mode'
+import type { PlanUnitState } from '@deepseek-ai/dsh-plan-mode/types'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
const PLAN_CONFIG = { section: 'Test plan mode instructions.' }
@@ -23,6 +25,7 @@ async function harness(adapter: MockAdapter): Promise {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
@@ -51,6 +54,13 @@ function waitForIdle(ctx: Context, agent: Agent): Promise {
})
}
+/** The logged-mode fold the plan projection unit serves: last `plan/mode` wins. */
+function foldPlanMode(events: readonly SessionEvent[]): boolean {
+ let state: PlanUnitState = planProjectionDefinition.init()
+ for (const event of events) state = planProjectionDefinition.apply(state, event)
+ return state.active
+}
+
function findEvent(
log: readonly SessionEvent[],
type: T,
diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts
index 8285147953..3a1602430b 100644
--- a/packages/plan/plan-mode/tests/plan-mode.spec.ts
+++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts
@@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
-import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
+import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserQuestionService, {
@@ -11,8 +11,11 @@ import UserQuestionService, {
} from '@deepseek-ai/dsh-user-questions'
import CommandRuntime from '@deepseek-ai/dsh-commands'
import { CodeRuntime, type CodeRunRequest, type CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
-import PlanModeController, { EXIT_PLAN_MODE, foldPlanMode, resolveConfig } from '../src/index.ts'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
+import PlanModeController, { EXIT_PLAN_MODE, planProjectionDefinition, resolveConfig } from '../src/index.ts'
import type { PlanModeConfig } from '../src/index.ts'
+import type { PlanUnitState } from '../src/types.ts'
const TEST_PLAN_SECTION = 'Test plan mode instructions.'
const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
@@ -65,8 +68,25 @@ function assembleFor(ctx: Context, agent: Agent) {
return ctx.systemPrompt.assemble({ agent, scope: agent })
}
+function foldPlanMode(events: readonly SessionEvent[], end = events.length): boolean {
+ let state: PlanUnitState = planProjectionDefinition.init()
+ let index = 0
+ for (const event of events) {
+ if (index >= end) break
+ index++
+ state = planProjectionDefinition.apply(state, event)
+ }
+ return state.active
+}
+
+async function mountProjectionSeam(ctx: Context): Promise {
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+}
+
async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise {
const ctx = new Context()
+ await mountProjectionSeam(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(PlanModeController, config)
@@ -282,6 +302,7 @@ describe('the boundary flush', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await mountProjectionSeam(ctx)
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
const agent = await agentWithSession(ctx)
openTurn(agent.session)
@@ -434,6 +455,7 @@ describe('the soft layer', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await mountProjectionSeam(ctx)
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
const final = await next()
final.tools = [...final.tools, { name: 'added-later', description: 'added after next()', parameters: {} }]
@@ -461,6 +483,7 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime, { mode: 'code' })
await ctx.plugin(FakeRuntime)
+ await mountProjectionSeam(ctx)
await ctx.plugin(PlanModeController, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
@@ -482,6 +505,7 @@ describe('the soft layer', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime, { mode: 'both' })
await ctx.plugin(FakeRuntime)
+ await mountProjectionSeam(ctx)
await ctx.plugin(PlanModeController, PLAN_CONFIG)
registerNamedTools(ctx, ['read', 'write'])
const agent = await agentWithSession(ctx, 'agent-1', { active: true })
@@ -503,6 +527,7 @@ describe('the soft layer', () => {
await withPlanMode.plugin(SystemPrompt)
await withPlanMode.plugin(ToolRuntime, { mode: 'code' })
await withPlanMode.plugin(FakeRuntime)
+ await mountProjectionSeam(withPlanMode)
await withPlanMode.plugin(PlanModeController, PLAN_CONFIG)
registerNamedTools(withPlanMode, ['read', 'write'])
const agent = await agentWithSession(withPlanMode)
@@ -716,6 +741,7 @@ describe('/plan', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(CommandRuntime)
+ await mountProjectionSeam(ctx)
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
await new Promise(resolve => setImmediate(resolve))
const agent = await agentWithSession(ctx)
@@ -862,6 +888,7 @@ describe('exit_plan_mode', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime, { mode: 'code' })
await ctx.plugin(ExitRuntime)
+ await mountProjectionSeam(ctx)
await ctx.plugin(PlanModeController, PLAN_CONFIG)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserQuestionService)
@@ -1037,6 +1064,7 @@ describe('exit_plan_mode', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await mountProjectionSeam(ctx)
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserQuestionService)
@@ -1101,6 +1129,7 @@ describe('HMR disposal', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await mountProjectionSeam(ctx)
const fiber = await ctx.plugin(PlanModeController, PLAN_CONFIG)
const agent = await agentWithSession(ctx, 'disposed-recovery')
openTurn(agent.session)
diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts
index e0ad20148c..e4f92cef03 100644
--- a/packages/plan/plan-mode/tests/projection.spec.ts
+++ b/packages/plan/plan-mode/tests/projection.spec.ts
@@ -1,14 +1,4 @@
-/**
- * The `plan` projection unit (session-projection RFC's complete example): a
- * event fold over the session log. `command/run` records named `plan` with
- * recorded input set the candidate target (`off` → false, anything else →
- * true); `command/done` keeps successful candidates and drops failures;
- * `plan/mode` commits and clears a selection. `view` reports pending only while
- * an outstanding selection differs from the logged state.
- * Pending is thereby a pure replay quantity — a cold fold answers it without
- * the service's in-memory intent. Composition without plan-mode has no `plan`
- * key; unloading the fiber removes it (HMR safety).
- */
+/** Plan projection behavior. */
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
@@ -48,20 +38,13 @@ async function harness(withPlanMode: boolean): Promise {
}
/** Append one logged /plan selection record (the executor's command/run shape). */
-function runPlanCommand(session: Session, args: string, index: number): CommandId {
- const commandId = CommandId(`plan-proj-${String(index)}`)
+function runPlanCommand(session: Session, args: string, index: number): void {
session.append('command/run', {
- commandId,
+ commandId: CommandId(`plan-proj-${String(index)}`),
name: 'plan',
args,
source: { kind: 'user' },
})
- return commandId
-}
-
-/** Append the paired settlement for one projected plan command. */
-function settlePlanCommand(session: Session, commandId: CommandId, kind: 'success' | 'error'): void {
- session.append('command/done', { commandId, kind })
}
/** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */
@@ -79,23 +62,15 @@ describe('plan projection unit', () => {
it('a logged /plan selection reads pending until plan/mode records it', async () => {
const bench = await harness(true)
- const commandId = runPlanCommand(bench.session, '', 0)
+ runPlanCommand(bench.session, '', 0)
expect(bench.values().plan).toEqual({ active: false, pending: true })
- settlePlanCommand(bench.session, commandId, 'success')
+ // A repeated identical selection returns the same state reference (no frame).
+ runPlanCommand(bench.session, '', 1)
expect(bench.values().plan).toEqual({ active: false, pending: true })
commitPlanMode(bench.session, true, 0)
expect(bench.values().plan).toEqual({ active: true, pending: false })
})
- it('drops a plan selection when its command settles with an error', async () => {
- const bench = await harness(true)
- commitPlanMode(bench.session, true, 0)
- const commandId = runPlanCommand(bench.session, 'off', 0)
- expect(bench.values().plan).toEqual({ active: true, pending: true })
- settlePlanCommand(bench.session, commandId, 'error')
- expect(bench.values().plan).toEqual({ active: true, pending: false })
- })
-
it('folds `off` args and non-plan commands correctly, and a matching selection is not pending', async () => {
const bench = await harness(true)
commitPlanMode(bench.session, true, 0)
@@ -124,6 +99,28 @@ describe('plan projection unit', () => {
expect(bench.values().plan).toEqual({ active: false, pending: true })
})
+ it('freezes the active state across a request/header into activeAtLastHeader', async () => {
+ 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()
+ 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)
+ 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)
+ 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)
+ })
+
it('has no plan key when plan-mode is not composed', async () => {
const bench = await harness(false)
expect('plan' in bench.values()).toBe(false)
@@ -144,7 +141,7 @@ describe('plan projection unit', () => {
// memory involved, the fold alone answers {active:false, pending:true}.
const cold = await harness(true)
for (const event of bench.session.events) {
- if (event.type === 'command/run' || event.type === 'command/done' || event.type === 'plan/mode') {
+ if (event.type === 'command/run' || event.type === 'plan/mode') {
cold.session.append(event.type, event.data)
}
}
diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json
index a8f374d765..78c45d8d8e 100644
--- a/packages/preset/agent-presets/package.json
+++ b/packages/preset/agent-presets/package.json
@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-settings-file": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts
index dda3644f55..6d26250fe1 100644
--- a/packages/preset/agent-presets/tests/invariant.spec.ts
+++ b/packages/preset/agent-presets/tests/invariant.spec.ts
@@ -7,6 +7,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
@@ -30,6 +31,7 @@ async function harness(roster: Partial = {}): Promise {
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'standard', roots: ROOTS, includeUserRoot: false, ...roster })
await ctx.plugin(InvariantRegistry)
diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts
index 824308e009..f5016d33df 100644
--- a/packages/preset/agent-presets/tests/mount.spec.ts
+++ b/packages/preset/agent-presets/tests/mount.spec.ts
@@ -9,6 +9,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry, { assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -48,6 +49,7 @@ async function harness(roster: Config = { default: 'standard', roots: ROOTS, inc
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, roster)
return ctx
@@ -417,6 +419,7 @@ describe('the preset file is an input, never a persistence target', () => {
await scoped.plugin(SystemPrompt, { persona: '' })
await scoped.plugin(ToolRuntime)
await scoped.plugin(AgentRegistry)
+ await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'self-disposing', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
@@ -582,6 +585,7 @@ describe('replacing a composition', () => {
await scoped.plugin(SystemPrompt, { persona: '' })
await scoped.plugin(ToolRuntime)
await scoped.plugin(AgentRegistry)
+ await scoped.plugin(SessionProjectionRegistry)
await scoped.plugin(AgentLoop, { agents: [] })
await scoped.plugin(AgentPresets, { default: 'first', roots: [{ path: root, trust: 'user' as const }], includeUserRoot: false })
const handle = await scoped.agents.create({
diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts
index e3af9e1ec1..436b344e38 100644
--- a/packages/preset/agent-presets/tests/settings.spec.ts
+++ b/packages/preset/agent-presets/tests/settings.spec.ts
@@ -15,6 +15,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
@@ -46,6 +47,7 @@ async function harness(
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
const settingsFiber = ctx.plugin(FileSettingsProvider, { path: settingsFile, watch: false })
await settingsFiber
diff --git a/packages/sandbox/sandbox-policy/README.i18n.yaml b/packages/sandbox/sandbox-policy/README.i18n.yaml
index f12249398a..cce8fe1531 100644
--- a/packages/sandbox/sandbox-policy/README.i18n.yaml
+++ b/packages/sandbox/sandbox-policy/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-policy/README.md
-README.md: c85420019b490d311360f310c04212ac6cd737d1
-README.zh.md: 58354adf55a55951cad1ea9adb88aea2457c7f7b
+README.md: 2e83fb5ffebb024cb6d9dc94708520f4130d10e9
+README.zh.md: cc0e24febadaa2f0232f9fb10158790ff5a9af19
diff --git a/packages/sandbox/sandbox-policy/README.md b/packages/sandbox/sandbox-policy/README.md
index c85420019b..2e83fb5ffe 100644
--- a/packages/sandbox/sandbox-policy/README.md
+++ b/packages/sandbox/sandbox-policy/README.md
@@ -18,7 +18,7 @@ Filesystem tools, one-shot bash commands, and terminal sessions may enforce the
- `ctx.sandboxPolicy.resolve({ session?, mode? })` — resolves one complete per-call policy. An explicit approved mode outranks the session's last `sandbox/mode` event, which outranks `defaultMode`; the session's immutable `cwd` is canonicalized with filesystem semantics before becoming `workspaceRoot`, otherwise the configured fallback applies. Canonicalization precedes lexical normalization so `symlink/..` agrees with process working-directory resolution.
- `ctx.sandboxPolicy.defaultMode` / `ctx.sandboxPolicy.workspaceRoot` — the deployment default and fallback root used by `resolve()`.
- `sandbox:policy` — a request-time cache-safe context contribution derived directly from `resolve({ session })`. It states the mode's capability-neutral file-effect contract and the canonical session workspace under `workspace-write`; tool owners retain operation-specific denial and escalation guidance.
-- `effectiveSandboxMode(events)` — the pure fold of a session's `sandbox/mode` events (the last switch wins, or `undefined`), used inside `resolve()`.
+- `overrideOf(session)` — the last `sandbox/mode` value from the required `sandboxMode` projection, or `undefined`, used inside `resolve()`.
- `setSandboxMode(session, mode)` — THE write path for a per-session override: appends exactly one `sandbox/mode` event. The switch IS its event; nothing mutates the mode out of band.
- `SANDBOX_MODES` — every mode, for option advertisement and runtime validation.
@@ -26,7 +26,7 @@ The optional `./invariant` companion rejects a forged durable `sandbox/mode` eve
## The per-session store
-A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? fold(events) ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot.
+A runtime switch is one log-only `sandbox/mode` event on the session it applies to. `effective = explicit grant ?? projection state ?? deployment default`, so an override survives restart by replay and two sessions never see each other's state. Workspace identity does not need another event: the immutable `SessionHeader.cwd` recorded at creation is the root for every call in that session. The event stays log-only; before the next request, the owner contributes the current fact to the full runtime-context snapshot.
## Model Experience
diff --git a/packages/sandbox/sandbox-policy/README.zh.md b/packages/sandbox/sandbox-policy/README.zh.md
index 58354adf55..cc0e24feba 100644
--- a/packages/sandbox/sandbox-policy/README.zh.md
+++ b/packages/sandbox/sandbox-policy/README.zh.md
@@ -18,7 +18,7 @@
- `ctx.sandboxPolicy.resolve({ session?, mode? })`:解析一项完整的逐调用策略。显式批准的模式优先于会话最后一条 `sandbox/mode` 事件,后者又优先于 `defaultMode`;会话不可变的 `cwd` 会先按文件系统语义规范化,再成为 `workspaceRoot`,否则使用配置的回退值。规范化先于词法归一化,因此 `symlink/..` 与进程工作目录解析保持一致。
- `ctx.sandboxPolicy.defaultMode`/`ctx.sandboxPolicy.workspaceRoot`:`resolve()` 使用的部署默认值与回退根目录。
- `sandbox:policy`:直接派生自 `resolve({ session })` 的请求时缓存安全上下文贡献。它说明该模式中与具体能力无关的文件操作约定,以及 `workspace-write` 下规范化的会话工作区;工具归属方仍负责特定于操作的拒绝与升权引导。
-- `effectiveSandboxMode(events)`:会话 `sandbox/mode` 事件的纯 fold(最后一次切换胜出,没有则为 `undefined`),在 `resolve()` 内使用。
+- `overrideOf(session)`:从必需的 `sandboxMode` 投影读取最后一项 `sandbox/mode` 值,没有则为 `undefined`;在 `resolve()` 内使用。
- `setSandboxMode(session, mode)`:逐会话覆盖的唯一写入路径:恰好追加一条 `sandbox/mode` 事件。切换本身就是事件;不会在带外修改模式。
- `SANDBOX_MODES`:所有模式,用于选项展示与运行时验证。
@@ -26,7 +26,7 @@
## 逐会话存储
-运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? fold(events) ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。
+运行时切换是在对应会话日志中追加的一条 `sandbox/mode` 事件。`effective = explicit grant ?? projection state ?? deployment default`,因此覆盖会通过回放跨重启保留,两个会话也绝不会看到彼此状态。工作区标识无需另一条事件:创建时记录的不可变 `SessionHeader.cwd` 是该会话每次调用使用的根。该事件仍只进入日志;在下一次请求前,归属方会将当前事实贡献给完整运行时上下文快照。
## 模型体验
diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json
index 3038e99718..21488a8275 100644
--- a/packages/sandbox/sandbox-policy/package.json
+++ b/packages/sandbox/sandbox-policy/package.json
@@ -37,10 +37,12 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:^"
+ "@deepseek-ai/schemastery": "workspace:^",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -48,6 +50,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts
index eee5a43669..5f3fa72382 100644
--- a/packages/sandbox/sandbox-policy/src/index.ts
+++ b/packages/sandbox/sandbox-policy/src/index.ts
@@ -20,14 +20,15 @@
import { resolve as resolvePath } from 'node:path'
import { Context, Service } from '@deepseek-ai/cordis'
+import { z as zod } from 'zod'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent'
import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type { Session } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-system-prompt'
-import { effectiveSandboxMode } from './session-mode.ts'
-export { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from './session-mode.ts'
+export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts'
/** Resolve filesystem identity before lexical normalization can erase symlink-sensitive components. */
function resolveWorkspaceRoot(path: string): string {
@@ -88,6 +89,22 @@ export interface SandboxPolicyRequest {
* section. Tool layers call {@link resolve} for each execution so a session's
* mode log and immutable cwd travel together to every enforcing capability.
*/
+/** The sandbox-mode projection's state schema (state equals the public shape). */
+const sandboxModeStateSchema = zod.union([
+ zod.literal('read-only'),
+ zod.literal('workspace-write'),
+ zod.literal('danger-full-access'),
+]).nullable()
+
+type SandboxModeState = zod.infer
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** Last logged sandbox-mode override, or null before one (deployment default applies at resolve time). */
+ sandboxMode: SandboxModeState
+ }
+}
+
+/** The sandbox policy seam: the deployment default mode and workspace-write root, with per-session overrides folded from the log. */
export class SandboxPolicyService extends Service {
// Inline schema call: the config catalog walks `static Config` statically.
static Config: z = z.object({
@@ -97,6 +114,8 @@ export class SandboxPolicyService extends Service {
workspaceRoot: z.string(),
})
+ static inject = ['sessionProjections']
+
/** The deployment default mode — the fallback beneath a session override. */
readonly defaultMode: SandboxMode
/** The absolute `workspace-write` fallback root for calls without a session cwd. */
@@ -109,6 +128,14 @@ export class SandboxPolicyService extends Service {
this.defaultMode = config.mode as SandboxMode
this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd())
+ ctx.sessionProjections.register({
+ key: 'sandboxMode',
+ stateVersion: 1,
+ stateSchema: sandboxModeStateSchema,
+ init: () => null,
+ apply: (state, event) => (event.type === 'sandbox/mode' ? event.data.mode : state),
+ })
+
ctx.inject(['systemPrompt'], (scope: Context) => {
scope.systemPrompt.context({
name: 'sandbox:policy',
@@ -147,7 +174,7 @@ export class SandboxPolicyService extends Service {
* @returns the last logged mode, or `undefined` without one.
*/
overrideOf(session: Session): SandboxMode | undefined {
- return effectiveSandboxMode(session.events)
+ return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined
}
}
diff --git a/packages/sandbox/sandbox-policy/src/session-mode.ts b/packages/sandbox/sandbox-policy/src/session-mode.ts
index 165a462046..0bc7110efc 100644
--- a/packages/sandbox/sandbox-policy/src/session-mode.ts
+++ b/packages/sandbox/sandbox-policy/src/session-mode.ts
@@ -2,7 +2,7 @@
* Per-session sandbox-mode override: the session log as the store. A runtime
* switch (a UI policy control or test scenario) is recorded as one
* `sandbox/mode` event on the session it applies to;
- * `effective = fold(events) ?? the deployment default`, so an override
+ * `effective = projection state ?? the deployment default`, so an override
* survives restart by replay, two sessions can never see each other's state,
* and there is no external config store. The event is log-only (the
* `approval/*` precedent): the policy owner projects the fold into each model
@@ -18,7 +18,7 @@
* @module dsh-sandbox-policy/session-mode
*/
-import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
+import type { Session } from '@deepseek-ai/dsh-session'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
declare module '@deepseek-ai/dsh-session/types' {
@@ -27,7 +27,7 @@ declare module '@deepseek-ai/dsh-session/types' {
* The session's sandbox mode was switched — log-only (like `approval/*`;
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
* never in the model transcript. The LAST such event is the session's
- * override ({@link effectiveSandboxMode}). `source: 'delegation'` marks
+ * override (folded by the sandboxMode projection unit). `source: 'delegation'` marks
* an override seeded into a child; an absent source is a runtime switch.
*/
'sandbox/mode': {
@@ -41,27 +41,11 @@ declare module '@deepseek-ai/dsh-session/types' {
/** Every {@link SandboxMode}, for option advertisement and runtime validation of untrusted mode strings. */
export const SANDBOX_MODES: readonly SandboxMode[] = ['read-only', 'workspace-write', 'danger-full-access']
-/**
- * The session's sandbox-mode override: the last `sandbox/mode` event in the
- * log, or undefined when the session never switched (callers apply the
- * deployment default). The pure fold — resume needs no catch-up machinery
- * because replaying the log IS the state.
- * @param events - session events in log order (other event types are skipped).
- * @returns the mode of the last switch event, or undefined without one.
- */
-export function effectiveSandboxMode(events: readonly SessionEvent[]): SandboxMode | undefined {
- for (let index = events.length - 1; index >= 0; index -= 1) {
- const event = events[index] as SessionEvent
- if (event.type === 'sandbox/mode') return event.data.mode
- }
- return undefined
-}
-
/**
* THE write path for a session's sandbox-mode override: appends exactly one
* `sandbox/mode` event — the switch IS its event; nothing mutates mode state
* out of band. Takes effect on the session's next confined call (bash or fs)
- * — the consumers fold on every read.
+ * — consumers read the shared projection state.
* @param session - the session the override belongs to.
* @param mode - the mode every subsequent confined call in this session runs
* under (until the next switch).
diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
index 0a239114aa..86c9fa3905 100644
--- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts
+++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts
@@ -11,11 +11,13 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
-import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import SandboxPolicyService, { SANDBOX_MODES, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SystemPrompt, { renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt'
async function mounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}) {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, config)
return ctx
}
@@ -125,6 +127,9 @@ describe('SandboxPolicyService', () => {
it('rejects a mode outside the closed vocabulary at load', async () => {
const ctx = new Context()
+ // Config validation runs when the fiber activates, and the policy seam
+ // requires the projection registry (mandatory injection) to activate.
+ await ctx.plugin(SessionProjectionRegistry)
// schemastery rejects the union violation when the plugin loads.
await expect(ctx.plugin(SandboxPolicyService, { mode: 'yolo' as never })).rejects.toThrow()
})
@@ -132,6 +137,7 @@ describe('SandboxPolicyService', () => {
it('disposes the service and context contribution from a child fiber (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(SandboxPolicyService, {})
expect(ctx.sandboxPolicy).toBeDefined()
expect(await policyContext(ctx, session('sess-hmr'))).toContain('read-only')
@@ -145,6 +151,7 @@ describe('sandbox:policy request context', () => {
async function promptMounted(config: { mode?: 'read-only' | 'workspace-write' | 'danger-full-access'; workspaceRoot?: string } = {}): Promise {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, config)
return ctx
}
@@ -212,12 +219,13 @@ describe('the sandbox/mode session kit', () => {
expect(SANDBOX_MODES).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
})
- it('effectiveSandboxMode folds to the last switch, or undefined without one', () => {
+ 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(effectiveSandboxMode(session.events)).toBeUndefined()
+ expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBeNull()
setSandboxMode(session, 'workspace-write')
setSandboxMode(session, 'read-only')
- expect(effectiveSandboxMode(session.events)).toBe('read-only')
+ expect(ctx.sessionProjections.snapshot(session).values.sandboxMode).toBe('read-only')
})
it('setSandboxMode appends exactly one sandbox/mode event per switch', () => {
diff --git a/packages/sandbox/sandbox-policy/tsconfig.json b/packages/sandbox/sandbox-policy/tsconfig.json
index e909a20be1..3a08a053b6 100644
--- a/packages/sandbox/sandbox-policy/tsconfig.json
+++ b/packages/sandbox/sandbox-policy/tsconfig.json
@@ -31,6 +31,9 @@
},
{
"path": "../../runtime-diagnostics/invariants"
+ },
+ {
+ "path": "../../session/session-projection"
}
]
}
diff --git a/packages/schedule/schedule/tests/jsonl-restart.spec.ts b/packages/schedule/schedule/tests/jsonl-restart.spec.ts
index 23371feee6..d18e35aac9 100644
--- a/packages/schedule/schedule/tests/jsonl-restart.spec.ts
+++ b/packages/schedule/schedule/tests/jsonl-restart.spec.ts
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -51,6 +52,7 @@ async function mountRuntime(root: string, adapter: RecordingAdapter): Promise {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(PersistenceProbe)
ctx.on('session/flush', () => {})
await ctx.plugin(AgentLoop, { agents: [] })
diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json
index ad55c29dd8..2216ee6b4c 100644
--- a/packages/sdk/server/package.json
+++ b/packages/sdk/server/package.json
@@ -57,6 +57,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts
index 495f1d90f4..c5f0019aa5 100644
--- a/packages/sdk/server/tests/server.spec.ts
+++ b/packages/sdk/server/tests/server.spec.ts
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
@@ -61,6 +62,7 @@ async function mockCompletionServer(): Promise<{ url: string; requests: unknown[
async function makeHarness(storageDir: string) {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(agentCore, { workspaceContext: false })
await ctx.plugin(SubagentRuntime)
await ctx.plugin(JsonlSessionPersistence, { root: storageDir })
diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json
index 4bab825e49..9926aead94 100644
--- a/packages/session-query/session-query-sqlite/package.json
+++ b/packages/session-query/session-query-sqlite/package.json
@@ -53,6 +53,7 @@
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts
index 1c4569df4c..4c528e9601 100644
--- a/packages/session-query/session-query-sqlite/src/index.ts
+++ b/packages/session-query/session-query-sqlite/src/index.ts
@@ -194,7 +194,7 @@ interface CursorPayload {
/** Concrete SQLite owner of the combined `ctx.sessionQuery` service. */
export class SqliteSessionQueryEngine extends SessionQueryEngine {
- static override inject = ['sessions']
+ static override inject = ['sessions', 'sessionProjections']
static Config: z = z.object({
path: z.string().required(),
diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts
index b68ac7072a..f38a4d53bb 100644
--- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts
+++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts
@@ -8,8 +8,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
-import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
-import SessionStore from '@deepseek-ai/dsh-session'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite'
import SqliteSessionQueryEngine, * as queryModule from '@deepseek-ai/dsh-session-query-sqlite'
import { mkdtemp, rm } from 'node:fs/promises'
@@ -35,6 +35,7 @@ describe('dsh-session-query-sqlite real Loader path', () => {
const persistencePath = await temporaryPath('canonical.db')
const searchPath = await temporaryPath('derived.db')
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionStore)
const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath })
diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
index 0a565fe021..93f5edf30a 100644
--- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
+++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts
@@ -6,6 +6,7 @@ import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
import type { SessionPersistenceSnapshot } from '@deepseek-ai/dsh-session-persistence'
@@ -182,6 +183,7 @@ class TestPersistence extends SessionPersistence {
async function liveContext(config: ConstructorParameters[1] = { path: ':memory:' }): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SqliteSessionQueryEngine, config)
return ctx
}
@@ -221,6 +223,7 @@ describe('SQLite session search', () => {
const path = await temporaryPath('unopened.db')
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const search = await ctx.plugin(SqliteSessionQueryEngine, {
path,
openAt: 'first-search',
@@ -235,6 +238,7 @@ describe('SQLite session search', () => {
const path = await temporaryPath('never-mode.db')
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const search = await ctx.plugin(SqliteSessionQueryEngine, { path, openAt: 'never' })
const service = ctx.sessionQuery as SqliteSessionQueryEngine
expect(service.config.openAt).toBe('never')
@@ -264,6 +268,7 @@ describe('SQLite session search', () => {
it('opens once on the first search and reuses readiness for later searches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SqliteSessionQueryEngine, {
path: ':memory:',
openAt: 'first-search',
@@ -281,6 +286,7 @@ describe('SQLite session search', () => {
it('shares one readiness promise across concurrent first searches', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SqliteSessionQueryEngine, {
path: ':memory:',
openAt: 'first-search',
@@ -1097,6 +1103,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
])
const first = new Context()
await first.plugin(SessionStore)
+ await first.plugin(SessionProjectionRegistry)
const firstPersistence = await first.plugin(TestPersistence)
const firstSearch = await first.plugin(SqliteSessionQueryEngine, { path })
await first.sessionQuery.searchSessions({ query: 'needle' })
@@ -1117,6 +1124,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.set({ meta: added, events: messageEvents('added needle') })
const second = new Context()
await second.plugin(SessionStore)
+ await second.plugin(SessionProjectionRegistry)
const secondPersistence = await second.plugin(TestPersistence)
const secondSearch = await second.plugin(SqliteSessionQueryEngine, { path })
const result = await second.sessionQuery.searchSessions({ query: 'needle' })
@@ -1146,6 +1154,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }])
const first = new Context()
await first.plugin(SessionStore)
+ await first.plugin(SessionProjectionRegistry)
const persistence = await first.plugin(TestPersistence)
const live = first.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 10 } })
const search = await first.plugin(SqliteSessionQueryEngine, { path })
@@ -1155,6 +1164,7 @@ describe('SQLite reconciliation and source lifecycle', () => {
const second = new Context()
await second.plugin(SessionStore)
+ await second.plugin(SessionProjectionRegistry)
const persistenceAgain = await second.plugin(TestPersistence)
const searchAgain = await second.plugin(SqliteSessionQueryEngine, { path })
await expect(second.sessionQuery.searchSessions({ query: 'live' })).resolves.toEqual({ items: [] })
@@ -1263,6 +1273,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const path = `${await temporaryPath()}\0`
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await expect(ctx.plugin(SqliteSessionQueryEngine, { path })).rejects.toMatchObject({
code: 'SESSION_QUERY_INDEX_FAILED',
@@ -1297,6 +1308,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
augmented.close()
const augmentedCtx = new Context()
await augmentedCtx.plugin(SessionStore)
+ await augmentedCtx.plugin(SessionProjectionRegistry)
await expect(augmentedCtx.plugin(SqliteSessionQueryEngine, { path: augmentedPath }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(augmentedCtx.sessionQuery).toBeUndefined()
@@ -1314,6 +1326,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
currentAugmented.close()
const currentAugmentedCtx = new Context()
await currentAugmentedCtx.plugin(SessionStore)
+ await currentAugmentedCtx.plugin(SessionProjectionRegistry)
await expect(currentAugmentedCtx.plugin(SqliteSessionQueryEngine, {
path: currentAugmentedPath,
journalMode: 'delete',
@@ -1334,6 +1347,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
foreign.close()
const foreignCtx = new Context()
await foreignCtx.plugin(SessionStore)
+ await foreignCtx.plugin(SessionProjectionRegistry)
await expect(foreignCtx.plugin(SqliteSessionQueryEngine, { path: foreignPath, journalMode: 'delete' }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(foreignCtx.sessionQuery).toBeUndefined()
@@ -1350,6 +1364,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
wildcard.close()
const wildcardCtx = new Context()
await wildcardCtx.plugin(SessionStore)
+ await wildcardCtx.plugin(SessionProjectionRegistry)
await expect(wildcardCtx.plugin(SqliteSessionQueryEngine, {
path: wildcardPath,
journalMode: 'delete',
@@ -1368,6 +1383,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
otherApp.close()
const otherAppCtx = new Context()
await otherAppCtx.plugin(SessionStore)
+ await otherAppCtx.plugin(SessionProjectionRegistry)
await expect(otherAppCtx.plugin(SqliteSessionQueryEngine, { path: otherAppPath }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(otherAppCtx.sessionQuery).toBeUndefined()
@@ -1384,6 +1400,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await expect(ctx.plugin(SqliteSessionQueryEngine, { path }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await new Promise((resolve) => { setImmediate(resolve) })
@@ -1402,6 +1419,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const lazyCtx = new Context()
await lazyCtx.plugin(SessionStore)
+ await lazyCtx.plugin(SessionProjectionRegistry)
const lazy = await lazyCtx.plugin(SqliteSessionQueryEngine, {
path,
openAt: 'first-search',
@@ -1413,6 +1431,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const eagerCtx = new Context()
await eagerCtx.plugin(SessionStore)
+ await eagerCtx.plugin(SessionProjectionRegistry)
await expect(eagerCtx.plugin(SqliteSessionQueryEngine, { path }))
.rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
expect(eagerCtx.sessionQuery).toBeUndefined()
@@ -1732,6 +1751,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
TestPersistence.reset()
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const search = await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:' })
const persistence = await ctx.plugin(TestPersistence)
const optional = (ctx.sessionQuery as unknown as {
@@ -1755,6 +1775,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const searchPath = await temporaryPath('derived.db')
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const persistence = await ctx.plugin(SqliteSessionPersistence, { path: persistencePath })
const search = await ctx.plugin(SqliteSessionQueryEngine, { path: searchPath })
const meta = header('real', 10, { cwd: '/work' })
@@ -1780,6 +1801,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const first = new Context()
await first.plugin(SessionStore)
+ await first.plugin(SessionProjectionRegistry)
const persistenceA = await first.plugin(SqliteSessionPersistence, { path: persistencePathA })
await first.sessionPersistence.create(shared)
await first.sessionPersistence.append(shared.id, messageEvents('alpha source'))
@@ -1793,6 +1815,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const reopened = new Context()
await reopened.plugin(SessionStore)
+ await reopened.plugin(SessionProjectionRegistry)
const persistenceAAgain = await reopened.plugin(SqliteSessionPersistence, { path: persistencePathA })
const reopenedInspect = vi.spyOn(reopened.sessionPersistence, 'inspect')
const searchAAgain = await reopened.plugin(SqliteSessionQueryEngine, { path: searchPath })
@@ -1804,6 +1827,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const second = new Context()
await second.plugin(SessionStore)
+ await second.plugin(SessionProjectionRegistry)
const persistenceB = await second.plugin(SqliteSessionPersistence, { path: persistencePathB })
await second.sessionPersistence.create(shared)
await second.sessionPersistence.append(shared.id, messageEvents('bravo source'))
diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts
index eb82890646..4eb75cda1e 100644
--- a/packages/session-query/session-query/tests/search-helpers.spec.ts
+++ b/packages/session-query/session-query/tests/search-helpers.spec.ts
@@ -5,6 +5,7 @@ import SessionStore, {
SESSION_FORMAT_VERSION,
SessionId,
} from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
buildSessionEventRecords,
@@ -274,6 +275,7 @@ describe('session-query document and filter helpers', () => {
it('exposes the scan path on the combined query service', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TestSessionQueryEngine)
const session = ctx.sessions.create(id)
session.append('user/message', createUserMessage({
@@ -290,6 +292,7 @@ describe('session-query document and filter helpers', () => {
it('registers exact and abstract search behavior under one ctx key', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(TestSessionQueryEngine)
const session = ctx.sessions.create(id)
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts
index 119188eb5a..83f5e1d040 100644
--- a/packages/session-query/session-query/tests/session-query.spec.ts
+++ b/packages/session-query/session-query/tests/session-query.spec.ts
@@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
import SessionQueryEngine, {
@@ -9,9 +10,11 @@ import SessionQueryEngine, {
type SessionEventSurface,
type SessionQueryErrorCode,
} from '@deepseek-ai/dsh-session-query'
-import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
+import { SessionTitleProviderId, SessionTitleService } from '@deepseek-ai/dsh-session-title'
import { TestSessionQueryEngine } from './test-service.ts'
+const TITLE_SERVICE_CONFIG = { fallbackMaxWords: 8, fallbackMaxBytes: 64, maxTitleBytes: 256 }
+
function header(id: string, createdAt = 1, extra: Partial = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
}
@@ -125,6 +128,7 @@ class TestPersistence extends SessionPersistence {
async function liveContext(config: ConstructorParameters[1] = {}): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TestSessionQueryEngine, config)
return ctx
}
@@ -487,6 +491,7 @@ describe('session-query exact reads', () => {
},
])
const ctx = await liveContext()
+ await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG)
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
shared.append('session/title', {
title: 'Live title',
@@ -525,6 +530,7 @@ describe('session-query exact reads', () => {
{ meta: second, events: [titleEvent('Second title', 20)] },
])
const ctx = await liveContext()
+ await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG)
await ctx.plugin(TestPersistence)
const signal = new AbortController().signal
const missing = SessionId('batch-title-missing')
@@ -748,6 +754,7 @@ describe('session-query exact reads', () => {
{ meta: malformed, events: [malformedTitle] },
])
const ctx = await liveContext()
+ await ctx.plugin(SessionTitleService, TITLE_SERVICE_CONFIG)
await ctx.plugin(TestPersistence)
TestPersistence.inspectOverride = (id) => {
if (id === failed.id) return Promise.reject(inspectFailure)
@@ -784,7 +791,7 @@ describe('session-query exact reads', () => {
})
expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' })
if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection')
- expect(results[2].reason).toBeInstanceOf(TypeError)
+ expect(results[2].reason).toBeInstanceOf(Error)
})
it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => {
@@ -1192,6 +1199,7 @@ describe('session-query exact reads', () => {
it('leaves the optional persistence dependency optional', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(TestSessionQueryEngine)
expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryEngine)
await fiber.dispose()
@@ -1202,6 +1210,7 @@ describe('session-query exact reads', () => {
TestPersistence.reset()
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const query = await ctx.plugin(TestSessionQueryEngine)
const persistence = await ctx.plugin(TestPersistence)
const optional = (ctx.sessionQuery as unknown as {
diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts
index 0bff6c1154..5a38eec21f 100644
--- a/packages/session-query/session-query/tests/tracing.spec.ts
+++ b/packages/session-query/session-query/tests/tracing.spec.ts
@@ -2,6 +2,7 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
@@ -99,6 +100,7 @@ class TracePersistence extends SessionPersistence {
async function queryContext(): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(TestSessionQueryEngine)
return ctx
}
diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json
index 8c069a5907..3b9fea73b1 100644
--- a/packages/session-query/tool-session-query/package.json
+++ b/packages/session-query/tool-session-query/package.json
@@ -39,7 +39,9 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
@@ -58,6 +60,8 @@
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^"
}
}
diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts
index d204184cfe..c36355d728 100644
--- a/packages/session-query/tool-session-query/src/index.ts
+++ b/packages/session-query/tool-session-query/src/index.ts
@@ -17,7 +17,7 @@ import { presentation } from './presentation.ts'
export const name = 'tool-session-query'
/** Capability services required by the model-facing consumer. */
-export const inject = ['tools', 'systemPrompt', 'sessionQuery']
+export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections']
/** Default maximum number of authorized search hits returned by one call. */
export const DEFAULT_MAX_SEARCH_RESULTS = 100
diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts
index a8010c0e90..88107783cb 100644
--- a/packages/session-query/tool-session-query/src/operations.ts
+++ b/packages/session-query/tool-session-query/src/operations.ts
@@ -57,7 +57,7 @@ async function executeSessionSearch(
exec: ToolRunContext,
maxResults: number,
): Promise {
- const caller = workspaceAccess.callerOf(exec)
+ const caller = workspaceAccess.callerOf(exec, ctx)
const cwd = caller.header.cwd
if (cwd === undefined) {
throw new HarnessError(
@@ -119,20 +119,22 @@ async function executeEventSearch(
exec: ToolRunContext,
maxResults: number,
): Promise {
- const caller = workspaceAccess.callerOf(exec)
+ 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 stepStart = caller.events.findLast(event => event.type === 'step/start')
- if (stepStart === undefined) {
+ const stepStartSeq = caller.boundary?.lastStepStartSeq
+ if (stepStartSeq === undefined) {
throw new HarnessError(
'current-session search requires an active step boundary',
'SESSION_QUERY_TOOL_NO_CURRENT_STEP',
)
}
- range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, stepStart.seq - 1)
+ // `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) {
@@ -170,7 +172,7 @@ async function executeSessionTrace(
args: SessionTargetArgs,
exec: ToolRunContext,
): Promise {
- const caller = workspaceAccess.callerOf(exec)
+ 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', () =>
@@ -203,7 +205,7 @@ async function executeEventTrace(
exec: ToolRunContext,
): Promise {
toolInput.assertNonNegativeSafeInteger('seq', args.seq)
- const caller = workspaceAccess.callerOf(exec)
+ 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', () =>
@@ -221,7 +223,7 @@ async function executeEventRead(
toolInput.assertNonNegativeSafeInteger('seq', args.seq)
if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before)
if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after)
- const caller = workspaceAccess.callerOf(exec)
+ const 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', () =>
diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts
index a6c7b1ce40..08d07d500b 100644
--- a/packages/session-query/tool-session-query/src/workspace-access.ts
+++ b/packages/session-query/tool-session-query/src/workspace-access.ts
@@ -12,17 +12,21 @@ import {
type SessionHeader,
type SessionId as SessionIdValue,
} from '@deepseek-ai/dsh-session'
+import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent'
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
readonly events: readonly SessionEvent[]
+ /** The caller's own-session boundary fold (the `turnBoundary` projection). */
+ readonly boundary: TurnBoundaryProjection | undefined
}
interface TitleView {
@@ -51,7 +55,7 @@ interface DescendantVisit {
readonly next: DescendantVisit | undefined
}
-function callerOf(exec: ToolRunContext): Caller {
+function callerOf(exec: ToolRunContext, ctx: Context): Caller {
const agent = exec.agent
if (agent === undefined) {
throw new HarnessError(
@@ -63,6 +67,7 @@ function callerOf(exec: ToolRunContext): Caller {
id: agent.session.id,
header: agent.session.header,
events: agent.session.events,
+ boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'),
}
}
diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts
index 500d7de3a6..d585bfd270 100644
--- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts
+++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts
@@ -10,6 +10,8 @@ import SessionStore, {
SessionId,
type Session,
} from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -30,6 +32,10 @@ function fakeAgent(session: Session): Agent {
return { id: session.id, session } as unknown as Agent
}
+function registerTurnBoundary(ctx: Context): void {
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+}
+
describe('tool-session-query with the real SQLite provider', () => {
it('searches live prior-step history and a persisted same-workspace log', { timeout: 20_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-tool-session-query-'))
@@ -37,6 +43,8 @@ describe('tool-session-query with the real SQLite provider', () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
@@ -106,6 +114,8 @@ describe('tool-session-query with the real SQLite provider', () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' })
diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
index 7d213f9591..25923c9247 100644
--- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
+++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts
@@ -11,6 +11,8 @@ import SessionStore, {
type SessionHeader,
type SessionId as SessionIdValue,
} from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SessionQueryEngine, {
SessionQueryError,
SessionSearchCursor,
@@ -195,6 +197,10 @@ interface Mounted {
call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise
}
+function registerTurnBoundary(ctx: Context): void {
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
+}
+
async function mount(
config: ToolSessionQuery.Config = {},
callerCwd: string | null = '/work',
@@ -203,6 +209,8 @@ async function mount(
const ctx = new Context()
activeContexts.push(ctx)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ registerTurnBoundary(ctx)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
if (enforceTimeout) await ctx.plugin(TimeoutPolicy)
@@ -1612,7 +1620,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () =>
{ query: 'q' },
{ agent: fakeAgent(noStep) },
)
- expect(errorCode(missing)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP')
+ expect(errorCode(missing)).toBe('SESSION_QUERY_INVALID_FILTER')
const other = createSession(mounted.ctx, 'paged-events', '/work')
const cursor = SessionSearchCursor('events-next')
@@ -1634,6 +1642,26 @@ 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 () => {
+ const ctx = new Context()
+ activeContexts.push(ctx)
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SystemPrompt)
+ await ctx.plugin(ToolRuntime)
+ await ctx.plugin(FakeQuery)
+ await ctx.plugin(ToolSessionQuery)
+ const session = createSession(ctx, 'no-boundary-caller', '/work')
+ const result = await ctx.tools.execute({
+ name: 'session_event_search',
+ arguments: { query: 'q' },
+ callId: CallId('call-no-boundary'),
+ signal: new AbortController().signal,
+ agent: fakeAgent(session),
+ })
+ expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP')
+ })
+
it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => {
const mounted = await mount()
const hit = createSession(mounted.ctx, 'hit', '/work')
diff --git a/packages/session-query/tool-session-query/tsconfig.json b/packages/session-query/tool-session-query/tsconfig.json
index d3ef530901..d9accb9886 100644
--- a/packages/session-query/tool-session-query/tsconfig.json
+++ b/packages/session-query/tool-session-query/tsconfig.json
@@ -4,7 +4,9 @@
"rootDir": "src",
"outDir": "lib/types"
},
- "include": ["src"],
+ "include": [
+ "src"
+ ],
"references": [
{
"path": "../../../vendor/cosmokit"
@@ -35,6 +37,12 @@
},
{
"path": "../../util/timeout"
+ },
+ {
+ "path": "../../session/session-projection"
+ },
+ {
+ "path": "../../core/agent"
}
]
}
diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json
index 1273bf3ce3..6479295b12 100644
--- a/packages/session/session-checkpoint-policy/package.json
+++ b/packages/session/session-checkpoint-policy/package.json
@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
index 9557746db9..4be7094ee5 100644
--- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
+++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts
@@ -2,6 +2,7 @@ import { writeFile } from 'node:fs/promises'
import { Context } from '@deepseek-ai/cordis'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
@@ -37,6 +38,7 @@ class CrashAdapter extends LlmAdapter {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' })
await ctx.plugin(checkpointPolicy)
diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml
index c1f659857b..a6f003b550 100644
--- a/packages/session/session-projection/README.i18n.yaml
+++ b/packages/session/session-projection/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
-README.md: 7ef1daedad2790bac31c48315b322f19914a29af
-README.zh.md: 928a2ea88fbc0c8e8fd3398fb0c87a7a8ed3b6e7
+README.md: bbdb9fc172bc3be36a7594772eeef5111f922eaf
+README.zh.md: 058fa6da0b1d43a438552d2c554e9f968d50ebb6
diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md
index 7ef1daedad..bbdb9fc172 100644
--- a/packages/session/session-projection/README.md
+++ b/packages/session/session-projection/README.md
@@ -27,7 +27,7 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
- **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`.
- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Client-visible units persist automatically; a host-only unit opts in with `persist: true`.
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
-- **Optional capability.** Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected; carriers use `ctx.get('sessionProjections')` and omit their block/frames entirely when the registry is absent.
+- **Required for units and host reads.** Plugins that contribute or read projection units inject `sessionProjections`, so incomplete compositions fail during activation. The lower-level api-proxy factory remains tolerant for isolated tests and diagnostics and omits projection blocks and frames when the registry is absent.
## Role
diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md
index 928a2ea88f..058fa6da0b 100644
--- a/packages/session/session-projection/README.zh.md
+++ b/packages/session/session-projection/README.zh.md
@@ -27,7 +27,7 @@
- **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。client-visible 单元自动持久化;host-only 单元以 `persist: true` 选择持久化。
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
-- **可选能力。** 领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响;载体使用 `ctx.get('sessionProjections')`,注册表缺席时完全省略自己的块与帧。
+- **单元与 host 读取方必需。** 贡献或读取投影单元的插件注入 `sessionProjections`,因此不完整的组合会在激活时失败。较低层的 api-proxy factory 仍对隔离测试和诊断保持容错,注册表缺席时省略投影块与帧。
## 职责
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index aa785cd996..d9d758c5ae 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -176,9 +176,9 @@ interface Registration {
* older than the registry, folds `init` over the in-memory log on first
* touch (event or read). Registration is an effect (disposer rides the
* calling fiber): an unloaded domain plugin's key disappears from snapshots
- * and clients read it as capability absence. Domain
- * plugins register under `ctx.inject(['sessionProjections'], …)` so headless
- * assemblies without the registry stay unaffected. Registrants sharing a key
+ * and clients read it as capability absence. Unit contributors and host readers
+ * require this service, so incomplete compositions fail during activation.
+ * Registrants sharing a key
* share one unit and are counted: the same tool package mounted in N agent
* presets registers N times, and the key survives until the last one
* unloads.
diff --git a/packages/session/session-stats/README.i18n.yaml b/packages/session/session-stats/README.i18n.yaml
index d12db01c69..43d5996252 100644
--- a/packages/session/session-stats/README.i18n.yaml
+++ b/packages/session/session-stats/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-stats/README.md
-README.md: 81b0de17e335b67936afd6fb11f15411beee3b76
-README.zh.md: 606628ea09bc34203a5374e49db62c1646293846
+README.md: bb6e6611d53678a900f48b4a9f7341aa73072015
+README.zh.md: 8cfb95cba7b73a770b876b9de0509ef9aa999378
diff --git a/packages/session/session-stats/README.md b/packages/session/session-stats/README.md
index 81b0de17e3..bb6e6611d5 100644
--- a/packages/session/session-stats/README.md
+++ b/packages/session/session-stats/README.md
@@ -21,7 +21,7 @@ Function plugin registering the `sessionStats` projection unit: whole-log conver
name: '@deepseek-ai/dsh-session-stats'
```
-Injects `sessionProjections` — the plugin's whole purpose; in assemblies without the registry the fiber stays pending and nothing registers.
+Requires `sessionProjections` — the plugin's whole purpose — so an incomplete assembly fails during activation.
## Model Experience
diff --git a/packages/session/session-stats/README.zh.md b/packages/session/session-stats/README.zh.md
index 606628ea09..8cfb95cba7 100644
--- a/packages/session/session-stats/README.zh.md
+++ b/packages/session/session-stats/README.zh.md
@@ -21,7 +21,7 @@
name: '@deepseek-ai/dsh-session-stats'
```
-注入 `sessionProjections`——这是插件的全部用途;在没有 registry 的装配中 fiber 保持挂起,不注册任何内容。
+要求注入 `sessionProjections`——这是插件的全部用途;不完整的装配会在激活时失败。
## 模型体验
diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json
index 6287d55029..e35f9d3f8d 100644
--- a/packages/session/session-title-all-prompts-llm/package.json
+++ b/packages/session/session-title-all-prompts-llm/package.json
@@ -43,6 +43,8 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^"
}
}
diff --git a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts
index c1f51c654b..8c358bfc35 100644
--- a/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts
+++ b/packages/session/session-title-all-prompts-llm/tests/provider.spec.ts
@@ -3,6 +3,8 @@ import { describe, expect, it } from 'vitest'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-prompts-llm'
@@ -44,6 +46,8 @@ describe('all-messages LLM title provider', () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()
ctx.llm.registerAdapter(['current-route'], adapter)
diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json
index a11c796906..1c7b8419cf 100644
--- a/packages/session/session-title-first-prompt-llm/package.json
+++ b/packages/session/session-title-first-prompt-llm/package.json
@@ -46,6 +46,8 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^"
}
}
diff --git a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts
index 12ff310ae9..7d003ecb73 100644
--- a/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts
+++ b/packages/session/session-title-first-prompt-llm/tests/loader-composition.spec.ts
@@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm'
@@ -38,6 +39,7 @@ async function loadComposition(): Promise {
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-session-title'",
' config:',
' fallbackMaxWords: 5',
@@ -62,6 +64,7 @@ async function loadComposition(): Promise {
const modules = new Map([
['@deepseek-ai/dsh-llm', LlmRuntime],
['@deepseek-ai/dsh-session', SessionStore],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-session-title', SessionTitleService],
['@deepseek-ai/dsh-session-title-first-prompt-llm', providerPlugin],
])
diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts
index 0bcdc98535..3b16d7349b 100644
--- a/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts
+++ b/packages/session/session-title-first-prompt-llm/tests/provider.e2e.ts
@@ -5,6 +5,7 @@ import LlmRuntime from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-prompt-llm'
const contexts: Context[] = []
@@ -20,6 +21,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-prompt title provider with
await ctx.plugin(LlmRuntime)
await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
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 c4fae95c87..083682bfa3 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
@@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest'
import LlmRuntime, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-prompt-llm'
@@ -36,6 +38,8 @@ describe('first-prompt LLM title provider', () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
let registered: SessionTitleProvider | undefined
vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
@@ -55,6 +59,8 @@ describe('first-prompt LLM title provider', () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()
ctx.llm.registerAdapter(['title-route'], adapter)
diff --git a/packages/session/session-title/README.i18n.yaml b/packages/session/session-title/README.i18n.yaml
index bbe1cfc651..c3210ca41a 100644
--- a/packages/session/session-title/README.i18n.yaml
+++ b/packages/session/session-title/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-title/README.md
-README.md: e923bd9700180214f235c4971d4b020565bfee43
-README.zh.md: 8e84ac27d9f1509c8530b838eeb5eb2216d87199
+README.md: 7949dbdb73d33c6c7ba1cccd469a6b22732aa95d
+README.zh.md: 24014485050be9e6aa736ac6c686416c3778ac10
diff --git a/packages/session/session-title/README.md b/packages/session/session-title/README.md
index e923bd9700..7949dbdb73 100644
--- a/packages/session/session-title/README.md
+++ b/packages/session/session-title/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
+Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event. The required `title` projection retains the full latest snapshot for `ctx.sessionTitle.get()` while its client view remains `string | null`; `foldSessionTitle()` remains the direct fold for detached logs.
Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
@@ -13,6 +13,8 @@ Only text blocks from human `user/message` events are eligible. The first eligib
- `rename(session, title)` accepts an explicit user title synchronously: it normalizes the text, supersedes in-flight automatic work, and appends a `session/title` event with the `user` source. A user-sourced latest title pins the session — later user messages schedule no automatic revision; an explicit `refresh` remains the deliberate unpin.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
+The service also registers the host-only `titleInput` projection. It incrementally retains eligible human text and the latest request route, so provider scheduling reads typed state through `stateOf()` instead of rescanning the session log.
+
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion appends a standalone log-only event directly through `Session` without opening a turn. Persistence observes that event eagerly and drains on ordinary lifecycle checkpoints; title publication itself does not force a flush. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their revision before provider work, while overlapping automatic and explicit fallback requests share one session-local in-flight append. The service and bundled model provider each append their own literal event type, so no generic title-write marker, cast, or settlement queue is needed. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-prompt cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
diff --git a/packages/session/session-title/README.zh.md b/packages/session/session-title/README.zh.md
index 8e84ac27d9..2401448505 100644
--- a/packages/session/session-title/README.zh.md
+++ b/packages/session/session-title/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件;`foldSessionTitle()` 与 `ctx.sessionTitle.get()` 会选择最新事件,并返回其事件 seq 和时间戳。
+由日志支持的会话标题,提供即时确定性回退与一个可选异步提供方。每次已接受的修订都是仅写入日志的 `session/title` 事件。必需的 `title` 投影为 `ctx.sessionTitle.get()` 保留完整最新快照,而客户端视图仍是 `string | null`;`foldSessionTitle()` 继续用于直接折叠脱离服务的日志。
只有用户 `user/message` 事件中的文本块符合条件。第一条符合条件的提示词会安排回退,从其开头若干词生成标题,并受所配置 UTF-8 字节上限约束。系统会规范化空白、移除终端控制序列,且截断绝不会切断码点。空提示词和非文本提示词会等待后续符合条件的输入。
@@ -13,6 +13,8 @@
- `rename(session, title)` 同步接受用户显式标题:规范化文本、取代在途自动工作,并追加一条 `user` 来源的 `session/title` 事件。用户来源的最新标题会钉住该会话——后续用户消息不再安排自动修订;显式 `refresh` 仍是有意的解钉手段。
- `register(provider)` 安装唯一可选提供方,并返回可等待的 Cordis effect disposer。第二次注册会立即抛出;对提供方执行 dispose(资源释放)会中止待处理和活跃调用,等待其结算,之后才允许注册另一个提供方。
+服务还会注册仅供 host 使用的 `titleInput` 投影。该投影增量保留符合条件的人类文本和最近一次请求 route,使提供方调度可以通过 `stateOf()` 读取类型化状态,无需重新扫描会话日志。
+
自动工作绝不会延迟主 agent(智能体)响应。只有当带标记、由循环构建的请求,其确切路由与当前已记录的 `request/header` 匹配时,提供方才会启动;即使请求头未变而无需新快照,也适用此规则。延迟完成会直接通过 `Session` 追加一个独立的纯日志事件,而不打开轮次。持久化会立即观察到该事件,并在常规生命周期检查点完成刷写;标题发布本身不会强制刷写。自动失败会发出警告并保留最新标题。新的全消息修订、提供方 dispose、会话 dispose 和显式刷新都会中止旧工作,陈旧的完成结果无法追加。并发显式刷新会在提供方工作之前预留修订号;重叠的自动/显式回退请求共享一个会话本地正在进行的追加操作。服务与内置模型提供方各自追加自己的字面事件类型,因此不需要通用标题写入标记、类型断言或结算队列。服务拆卸会取消排队工作,并在卸载完成前等待不响应取消的调用结算完成。
fork 出的会话会原样继承种子中的标题事件。首消息节奏不会自动为子会话重新生成标题;全消息节奏可以在子会话收到后续用户提示词后追加新修订。
diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json
index 3395816244..352f977661 100644
--- a/packages/session/session-title/package.json
+++ b/packages/session/session-title/package.json
@@ -46,7 +46,8 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
@@ -60,6 +61,8 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^"
}
}
diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts
index 5ed0f1ca4d..7f467e6d79 100644
--- a/packages/session/session-title/src/index.ts
+++ b/packages/session/session-title/src/index.ts
@@ -6,26 +6,38 @@
import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { z as zod } from 'zod'
-import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { ZodType } from 'zod'
import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
Session,
SessionEvent,
} from '@deepseek-ai/dsh-session'
-// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
-// The `title` projection-key declaration lives in src/types.ts (its one home);
-// this re-export projects the type face onto the package root AND keeps the
-// module edge in the emitted index.d.ts, so aggregate programs consuming the
-// declarations still receive the SessionProjectionMap merge.
-export type * from './types.ts'
+import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
+import type {} from '@deepseek-ai/dsh-agent'
+export type {
+ SessionTitleEventData,
+ SessionTitleModelProvenance,
+ SessionTitleSnapshot,
+ SessionTitleSource,
+ SessionTitleUserMessage,
+ TitleProjection,
+} from './types.ts'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
+import type {
+ SessionTitleEventData,
+ SessionTitleModelProvenance,
+ SessionTitleSnapshot,
+ SessionTitleSource,
+ SessionTitleUserMessage,
+ TitleInputChunk,
+ TitleInputState,
+ TitleProjection,
+} from './types.ts'
-export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
-
-/** Identifies one session-title provider registration. */
-export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
+/** Branded session-title provider identity. */
+export type SessionTitleProviderId = import('./types.ts').SessionTitleProviderId
/**
* Brand a raw provider id.
@@ -36,44 +48,7 @@ export function SessionTitleProviderId(id: string): SessionTitleProviderId {
return id as SessionTitleProviderId
}
-/** Exact auxiliary model route that produced a title. */
-export interface SessionTitleModelProvenance {
- /** Registered LLM provider route. */
- readonly provider: string
- /** Provider model id. */
- readonly model: string
-}
-
-/** Durable ownership record for an accepted session title. */
-export type SessionTitleSource =
- | { readonly kind: 'fallback' }
- | {
- readonly kind: 'provider'
- readonly provider: SessionTitleProviderId
- readonly model?: SessionTitleModelProvenance
- }
- | {
- /** Explicit user rename: pins the title — automatic generation stops scheduling. */
- readonly kind: 'user'
- }
-
-/** Payload of the log-only `session/title` event. */
-export interface SessionTitleEventData {
- /** Normalized non-empty title text. */
- readonly title: string
- /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
- readonly messageSeqs: number[]
- /** Whether the built-in fallback, a registered provider, or the user supplied the title. */
- readonly source: SessionTitleSource
-}
-
-/** Latest folded title plus the title event's durable envelope facts. */
-export interface SessionTitleSnapshot extends SessionTitleEventData {
- /** Seq of the latest `session/title` event. */
- readonly eventSeq: number
- /** Timestamp of the latest `session/title` event. */
- readonly updatedAt: number
-}
+export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
/** Required deterministic fallback and accepted-title limits. */
export interface Config {
@@ -111,14 +86,6 @@ export class SessionTitleInvalidError extends Error {
override readonly name = 'SessionTitleInvalidError'
}
-/** One eligible human text message exposed to title providers. */
-export interface SessionTitleUserMessage {
- /** Source `user/message` event seq. */
- readonly seq: number
- /** Exact concatenated text-block content. */
- readonly text: string
-}
-
/** Automatic generation cadence owned by a registered provider. */
export type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts'
@@ -158,48 +125,17 @@ export interface SessionTitleProvider {
generate(request: SessionTitleProviderRequest): Promise
}
-/**
- * Collect human text-bearing user messages in log order.
- * @param events - session log or persisted replay.
- * @param throughSeq - optional inclusive event boundary.
- * @returns eligible messages with exact source seqs.
- */
-export function collectSessionTitleMessages(
- events: readonly SessionEvent[],
- throughSeq?: number,
-): SessionTitleUserMessage[] {
- const messages: SessionTitleUserMessage[] = []
- for (const event of events) {
- if (throughSeq !== undefined && event.seq > throughSeq) break
- if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
- const content = event.data.content
- const text = content
- .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
- .map(block => block.text)
- .join('\n')
- if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
- messages.push({ seq: event.seq, text })
- }
- return messages
+/** Extract one eligible human text message from a session event. */
+function sessionTitleUserMessageOf(event: SessionEvent): SessionTitleUserMessage | undefined {
+ if (event.type !== 'user/message' || event.data.source.kind !== 'user') return undefined
+ const content = event.data.content
+ const text = content
+ .filter((block): block is Extract<(typeof content)[number], { type: 'text' }> => block.type === 'text')
+ .map(block => block.text)
+ .join('\n')
+ if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) return undefined
+ return { seq: event.seq, text }
}
-
-/**
- * Fold the latest logged title without consulting mutable metadata.
- * @param events - live or persisted session log.
- * @returns the latest immutable title snapshot, or `undefined`.
- */
-export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
- const event = events.findLast(item => item.type === 'session/title')
- if (event === undefined) return undefined
- return deepFreeze({
- title: event.data.title,
- messageSeqs: [...event.data.messageSeqs],
- source: copySessionTitleSource(event.data.source),
- eventSeq: event.seq,
- updatedAt: event.time,
- })
-}
-
/** Defensive copy of a logged title source (the snapshot must not alias log-owned objects). */
function copySessionTitleSource(source: SessionTitleSource): SessionTitleSource {
switch (source.kind) {
@@ -257,9 +193,94 @@ function assertPositiveInteger(name: keyof Config, value: number): void {
}
}
+/** The title unit's folded state — the projection's full public face. */
+export type TitleUnitState = TitleProjection
+
+/**
+ * Convert title projection state into an immutable snapshot.
+ * @param state - the title unit's folded state.
+ * @returns the immutable snapshot.
+ */
+export function titleSnapshotFromState(state: TitleUnitState): SessionTitleSnapshot {
+ return deepFreeze({
+ title: state.title,
+ messageSeqs: [...state.messageSeqs],
+ source: copySessionTitleSource(state.source),
+ eventSeq: state.eventSeq,
+ updatedAt: state.updatedAt,
+ })
+}
+
+const TITLE_INPUT_CHUNK_SIZE = 64
+
+const EMPTY_TITLE_INPUT: TitleInputState = { first: null, last: null, count: 0, tail: null }
+
+function titleInputPrefix(state: TitleInputState, throughSeq: number): SessionTitleUserMessage[] {
+ const chunks: TitleInputChunk[] = []
+ for (let chunk = state.tail; chunk !== null; chunk = chunk.previous) chunks.push(chunk)
+ const prefix: SessionTitleUserMessage[] = []
+ for (const chunk of chunks.reverse()) {
+ for (const message of chunk.messages) {
+ if (message.seq <= throughSeq) prefix.push(message)
+ }
+ }
+ return prefix
+}
+
+// Zod cannot express the branded provider id without a runtime transform.
+const titleProjectionSchema = zod.object({
+ title: zod.string().min(1),
+ messageSeqs: zod.array(zod.number().int().nonnegative()),
+ source: zod.discriminatedUnion('kind', [
+ zod.object({ kind: zod.literal('fallback') }),
+ zod.object({
+ kind: zod.literal('provider'),
+ provider: zod.string(),
+ model: zod.object({ provider: zod.string(), model: zod.string() }).optional(),
+ }),
+ zod.object({ kind: zod.literal('user') }),
+ ]),
+ eventSeq: zod.number().int().nonnegative(),
+ updatedAt: zod.number(),
+}).nullable() as unknown as ZodType
+
+const titleViewSchema: ZodType = zod.string().min(1).nullable()
+
+/** Latest logged title and its client view. */
+export const titleProjectionDefinition = {
+ key: 'title',
+ stateVersion: 2,
+ stateSchema: titleProjectionSchema,
+ init: () => null,
+ apply: (state, event) => (event.type === 'session/title'
+ ? {
+ title: event.data.title,
+ messageSeqs: event.data.messageSeqs,
+ source: event.data.source,
+ eventSeq: event.seq,
+ updatedAt: event.time,
+ }
+ : state),
+ wire: {
+ viewSchema: titleViewSchema,
+ view: state => state?.title ?? null,
+ },
+} satisfies ProjectionDefinition<'title', TitleUnitState | null>
+
+/**
+ * Fold the latest title from a session log.
+ * @param events - live or persisted session log.
+ * @returns the immutable latest title snapshot, or `undefined`.
+ */
+export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
+ let state: TitleUnitState | null = titleProjectionDefinition.init()
+ for (const event of events) state = titleProjectionDefinition.apply(state, event)
+ return state === null ? undefined : titleSnapshotFromState(state)
+}
+
/** Log-backed title fold plus asynchronous fallback generation. */
export class SessionTitleService extends Service {
- static inject = ['sessions']
+ static inject = ['sessions', 'sessionProjections']
static Config: z = z.object({
fallbackMaxWords: z.number().step(1).min(1).required(),
fallbackMaxBytes: z.number().step(1).min(1).required(),
@@ -301,20 +322,26 @@ export class SessionTitleService extends Service {
this.work.clear()
}, 'sessionTitle lifecycle')
- // The title projection unit: pure last-wins fold of session/title events
- // (the same events foldSessionTitle consumes), serving the plain title
- // string clients list rows read. The unit child activates only when a
- // projection registry is composed (headless assemblies stay unaffected).
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- const titleSchema = zod.union([zod.string().min(1), zod.null()])
- projectionCtx.sessionProjections.register<'title', string | null>({
- key: 'title',
- stateSchema: titleSchema,
- init: () => null,
- apply: (state, event) => (event.type === 'session/title' ? event.data.title : state),
- wire: { viewSchema: titleSchema, view: state => state },
- stateVersion: 1,
- })
+ ctx.sessionProjections.register(titleProjectionDefinition)
+
+ ctx.sessionProjections.register<'titleInput', TitleInputState>({
+ key: 'titleInput',
+ stateVersion: 1,
+ stateSchema: zod.custom(),
+ init: () => EMPTY_TITLE_INPUT,
+ apply: (state, event) => {
+ const message = sessionTitleUserMessageOf(event)
+ if (message === undefined) return state
+ const tail = state.tail === null || state.tail.messages.length >= TITLE_INPUT_CHUNK_SIZE
+ ? { messages: [message], previous: state.tail }
+ : { messages: [...state.tail.messages, message], previous: state.tail.previous }
+ return {
+ first: state.first ?? message,
+ last: message,
+ count: state.count + 1,
+ tail,
+ }
+ },
})
ctx.on('session/event', (session, event) => {
@@ -347,7 +374,8 @@ export class SessionTitleService extends Service {
* @returns latest title snapshot, or `undefined` before eligible input.
*/
get(session: Session): SessionTitleSnapshot | undefined {
- return foldSessionTitle(session.events)
+ const state = this.ctx.sessionProjections.stateOf(session, 'title')
+ return state === null || state === undefined ? undefined : titleSnapshotFromState(state)
}
/**
@@ -397,15 +425,15 @@ export class SessionTitleService extends Service {
throw new Error(`session "${session.id}" is not live in this store`)
}
const registration = this.registration
- const messages = collectSessionTitleMessages(session.events)
- const latest = messages.at(-1)
- if (registration === undefined || registration.closing || latest === undefined) {
+ const input = this.titleInputOf(session)
+ const latest = input.last
+ if (registration === undefined || registration.closing || latest === null) {
// Explicit refresh is the unpin even without a provider: a standing
// user title must not short-circuit ensureFallback into a no-op, so
// re-derive and append the fallback over it when one is derivable.
const current = this.get(session)
- const [first] = messages
- if (current?.source.kind === 'user' && first !== undefined) {
+ const first = input.first
+ if (current?.source.kind === 'user' && first !== null) {
this.appendFallback(session, first)
signal?.throwIfAborted()
return this.get(session)
@@ -462,14 +490,14 @@ export class SessionTitleService extends Service {
/** Schedule fallback creation and any provider cadence for one eligible event. */
private onUserMessage(session: Session, event: Extract): void {
if (!this.serviceActive()) return
- if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
+ if (event.data.source.kind !== 'user' || sessionTitleUserMessageOf(event) === undefined) return
// A user rename pins the title: no automatic revision may override it.
if (this.get(session)?.source.kind === 'user') return
const registration = this.registration
if (registration !== undefined && !registration.closing) {
- const messages = collectSessionTitleMessages(session.events, event.seq)
+ const count = this.titleInputOf(session).count
const shouldSchedule = registration.provider.automatic === 'all-prompts'
- || (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
+ || (session.header.parentSession === undefined && count === 1 && this.get(session) === undefined)
if (shouldSchedule) {
const state = this.stateFor(session)
const revision = this.supersede(state, 'newer user message superseded title generation')
@@ -506,9 +534,9 @@ 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 = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
+ const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary
const route = session.requestHeader()?.config
- if (boundary?.type !== 'step/start'
+ if (boundary?.kind !== 'start'
|| boundary.seq <= pending.throughSeq
|| route?.provider !== options.provider
|| route.model !== options.model) return
@@ -558,7 +586,7 @@ export class SessionTitleService extends Service {
this.assertCurrent(session, work)
await this.ensureFallback(session)
this.assertCurrent(session, work)
- const messages = collectSessionTitleMessages(session.events, work.throughSeq)
+ const messages = titleInputPrefix(this.titleInputOf(session), work.throughSeq)
const result = await work.registration.provider.generate({
session,
messages,
@@ -680,6 +708,11 @@ export class SessionTitleService extends Service {
return state
}
+ private titleInputOf(session: Session): TitleInputState {
+ /* v8 ignore next -- session-title registers its own titleInput unit, so the key is always present */
+ return this.ctx.sessionProjections.stateOf(session, 'titleInput') ?? EMPTY_TITLE_INPUT
+ }
+
/** Queue detached service work and retain it through service disposal. */
private defer(task: () => Promise): void {
const run = Promise.resolve().then(async () => {
@@ -757,8 +790,8 @@ export class SessionTitleService extends Service {
this.assertServiceActive()
const current = this.get(session)
if (current !== undefined) return current
- const [first] = collectSessionTitleMessages(session.events)
- if (first === undefined) return undefined
+ const first = this.titleInputOf(session).first
+ if (first === null) return undefined
const title = fallbackSessionTitle(
first.text,
this.config.fallbackMaxWords,
diff --git a/packages/session/session-title/src/types.ts b/packages/session/session-title/src/types.ts
index ad93926202..77c6d3e78e 100644
--- a/packages/session/session-title/src/types.ts
+++ b/packages/session/session-title/src/types.ts
@@ -2,19 +2,93 @@
* Pure types of the title domain: the ONE home of the `title` projection-key
* declaration, free of this package's host-side value imports (cordis
* service, schemastery, the llm seam). Two namespace projections serve it —
- * `./types` for host consumers, `./client/types` (the browser half-entry's
- * re-export) for client aggregates — with zero content duplication.
+ * `./types` for host consumers and `./client/types` for client aggregates —
+ * with zero content duplication.
*
* @module @deepseek-ai/dsh-session-title/types
*/
-// Marks this file a module so the declaration below AUGMENTS the projection
-// table instead of declaring an ambient module.
export {}
+import type { Branded } from '@deepseek-ai/dsh-brand'
+
+/** Identifies one session-title provider registration. */
+export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
+
+/** Exact auxiliary model route that produced a title. */
+export interface SessionTitleModelProvenance {
+ /** Registered LLM provider route. */
+ readonly provider: string
+ /** Provider model id. */
+ readonly model: string
+}
+
+/** Durable ownership record for an accepted session title. */
+export type SessionTitleSource =
+ | { readonly kind: 'fallback' }
+ | {
+ readonly kind: 'provider'
+ readonly provider: SessionTitleProviderId
+ readonly model?: SessionTitleModelProvenance
+ }
+ | {
+ /** Explicit user rename: pins the title — automatic generation stops scheduling. */
+ readonly kind: 'user'
+ }
+
+/** Payload of the log-only `session/title` event. */
+export interface SessionTitleEventData {
+ /** Normalized non-empty title text. */
+ readonly title: string
+ /** Exact human `user/message` seqs used to derive this title; empty for an explicit user rename. */
+ readonly messageSeqs: number[]
+ /** Whether the built-in fallback, a registered provider, or the user supplied the title. */
+ readonly source: SessionTitleSource
+}
+
+/** Latest folded title plus the title event's durable envelope facts. */
+export interface SessionTitleSnapshot extends SessionTitleEventData {
+ /** Seq of the latest `session/title` event. */
+ readonly eventSeq: number
+ /** Timestamp of the latest `session/title` event. */
+ readonly updatedAt: number
+}
+
+/** Host title projection value. */
+export type TitleProjection = SessionTitleSnapshot
+
+/** One eligible human text message exposed to title providers. */
+export interface SessionTitleUserMessage {
+ /** Source `user/message` event seq. */
+ readonly seq: number
+ /** Exact concatenated text-block content. */
+ readonly text: string
+}
+
+/** One bounded reverse-linked group of eligible title-input messages. */
+export interface TitleInputChunk {
+ readonly messages: readonly SessionTitleUserMessage[]
+ readonly previous: TitleInputChunk | null
+}
+
+/** Eligible title input stored in bounded reverse-linked chunks. */
+export interface TitleInputState {
+ /** The oldest eligible message, or null before any. */
+ readonly first: SessionTitleUserMessage | null
+ /** Total eligible messages folded so far. */
+ readonly count: number
+ /** Newest eligible message, or null before any. */
+ readonly last: SessionTitleUserMessage | null
+ /** Newest chunk in the reverse-linked list. */
+ readonly tail: TitleInputChunk | null
+}
+
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
- title: string | null
+ /** Latest logged title, or null. */
+ title: TitleProjection | null
+ /** Eligible human title input. */
+ titleInput: TitleInputState
}
interface SessionProjectionMap {
/**
diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts
index a886e62db3..18b5f57aca 100644
--- a/packages/session/session-title/tests/persistence.spec.ts
+++ b/packages/session/session-title/tests/persistence.spec.ts
@@ -5,6 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SqliteSessionPersistence from '@deepseek-ai/dsh-session-persistence-sqlite'
import SessionTitleService, { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
@@ -57,6 +58,7 @@ describe('session title persistence round trips', () => {
const id = SessionId('title-jsonl')
const writer = new Context()
await writer.plugin(SessionStore)
+ await writer.plugin(SessionProjectionRegistry)
await writer.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
@@ -64,6 +66,7 @@ describe('session title persistence round trips', () => {
const reader = new Context()
await reader.plugin(SessionStore)
+ await reader.plugin(SessionProjectionRegistry)
await reader.plugin(JsonlSessionPersistence, { root, compression: 'none' })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
@@ -76,6 +79,7 @@ describe('session title persistence round trips', () => {
const id = SessionId('title-sqlite')
const writer = new Context()
await writer.plugin(SessionStore)
+ await writer.plugin(SessionProjectionRegistry)
await writer.plugin(SqliteSessionPersistence, { path })
await writer.plugin(SessionTitleService, CONFIG)
await appendPersistedTitle(writer, id)
@@ -83,6 +87,7 @@ describe('session title persistence round trips', () => {
const reader = new Context()
await reader.plugin(SessionStore)
+ await reader.plugin(SessionProjectionRegistry)
await reader.plugin(SqliteSessionPersistence, { path })
await expectPersistedTitle(reader, id)
await reader.fiber.dispose()
diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts
index 1198e78a23..d518f9f756 100644
--- a/packages/session/session-title/tests/projection.spec.ts
+++ b/packages/session/session-title/tests/projection.spec.ts
@@ -1,16 +1,6 @@
-/**
- * The `title` projection unit: mounting the title service beside the
- * projection registry serves the current normalized title (last-wins over
- * session/title events, the same events foldSessionTitle consumes) — null
- * before the first title — through the registry snapshot and the change
- * feed; compositions without the registry are unaffected; unmounting the
- * service removes the key (HMR safety). The bespoke session/title mux frame
- * is untouched by this unit (its retirement is the client value-store
- * migration's concern).
- */
-
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
+import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
@@ -26,7 +16,6 @@ async function harness(withTitleService: boolean): Promise<{ ctx: Context; sessi
return { ctx, session: ctx.sessions.create(SessionId('titled')) }
}
-/** Append one session/title event directly (the replay-plane shape the unit folds). */
function appendTitle(session: Session, title: string): number {
return session.append('session/title', { title, messageSeqs: [1], source: { kind: 'fallback' } }).seq
}
@@ -46,7 +35,6 @@ describe('title projection unit', () => {
})
const firstSeq = appendTitle(session, 'First title')
const secondSeq = appendTitle(session, 'Second title')
- // Unrelated event: same-reference apply, no notification.
session.append('turn/start', { turn: 1 })
expect(changes).toEqual([
{ key: 'title', value: 'First title', seq: firstSeq },
@@ -73,4 +61,23 @@ describe('title projection unit', () => {
await fiber.dispose()
expect('title' in ctx.sessionProjections.snapshot(session).values).toBe(false)
})
+
+ it('keeps thousands of title inputs in bounded reverse-linked chunks without persisting them', async () => {
+ const { ctx, session } = await harness(false)
+ session.append('turn/start', { turn: 1 })
+ for (let index = 0; index < 5_000; index++) {
+ session.append('user/message', createUserMessage({
+ content: [{ type: 'text', text: `message ${String(index)}` }],
+ source: { kind: 'user' },
+ }), { surfaceOp: 'append' })
+ }
+ await ctx.plugin(SessionTitleService, CONFIG)
+
+ const state = ctx.sessionProjections.stateOf(session, 'titleInput')
+ expect(state?.count).toBe(5_000)
+ let chunks = 0
+ for (let chunk = state?.tail ?? null; chunk !== null; chunk = chunk.previous) chunks += 1
+ expect(chunks).toBe(Math.ceil(5_000 / 64))
+ expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeUndefined()
+ })
})
diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts
index 7843ff2c22..53fc323d38 100644
--- a/packages/session/session-title/tests/provider.spec.ts
+++ b/packages/session/session-title/tests/provider.spec.ts
@@ -2,6 +2,8 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmRuntime, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SessionTitleService, {
SessionTitleProviderId,
type SessionTitleProvider,
@@ -51,6 +53,7 @@ describe('SessionTitleService Provider lifecycle', () => {
it('inherits title events across forks, skips first-prompt retitling, and lets all-messages update later', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const parent = ctx.sessions.create(SessionId('title-parent'))
parent.append('turn/start', {
@@ -115,6 +118,7 @@ describe('SessionTitleService Provider lifecycle', () => {
it('runs a first-prompt provider once after the routed request and retries only through refresh', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
const provider: SessionTitleProvider = {
@@ -167,9 +171,75 @@ describe('SessionTitleService Provider lifecycle', () => {
expect(requests[1]?.messages.map(message => message.seq)).toEqual([first.seq, second.seq])
})
+ it('preserves provider input order across bounded title-input chunks', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SessionTitleService, CONFIG)
+ const session = ctx.sessions.create(SessionId('chunked-provider-input'))
+ session.append('turn/start', { turn: 1 })
+ const messages = Array.from({ length: 70 }, (_, index) =>
+ appendHumanPrompt(session, `Prompt ${String(index)}`))
+ await settle()
+ const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({
+ title: 'Chunked history',
+ messageSeqs: request.messages.map(message => message.seq),
+ }))
+ ctx.sessionTitle.register({
+ id: SessionTitleProviderId('chunked-provider'),
+ automatic: 'all-prompts',
+ generate,
+ })
+
+ await ctx.sessionTitle.refresh(session)
+
+ expect(generate).toHaveBeenCalledOnce()
+ expect(generate.mock.calls[0]?.[0].messages).toEqual(messages.map((message, index) => ({
+ seq: message.seq,
+ text: `Prompt ${String(index)}`,
+ })))
+ })
+
+ it('cuts a first-message provider request at its scheduled watermark when a newer prompt lands first', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SessionTitleService, CONFIG)
+ const requests: SessionTitleProviderRequest[] = []
+ ctx.sessionTitle.register({
+ id: SessionTitleProviderId('watermark-cut'),
+ automatic: 'first-prompt',
+ async generate(request) {
+ requests.push(request)
+ return {
+ title: 'Watermarked title',
+ messageSeqs: request.messages.map(message => message.seq),
+ }
+ },
+ })
+ const session = ctx.sessions.create(SessionId('watermark-cut'))
+ session.append('turn/start', {
+ turn: 1,
+ })
+ const first = appendHumanPrompt(session, 'First prompt')
+ await settle()
+ appendHumanPrompt(session, 'A newer prompt before the route')
+ appendRoute(session)
+ await settle()
+
+ expect(requests).toHaveLength(1)
+ expect(requests[0]?.messages).toEqual([{ seq: first.seq, text: 'First prompt' }])
+ expect(ctx.sessionTitle.get(session)).toMatchObject({
+ title: 'Watermarked title',
+ messageSeqs: [first.seq],
+ source: { kind: 'provider', provider: SessionTitleProviderId('watermark-cut') },
+ })
+ })
+
it('rejects a second provider and drains stale work when the winner is disposed', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const pending = deferred()
let observedSignal: AbortSignal | undefined
@@ -221,6 +291,7 @@ describe('SessionTitleService Provider lifecycle', () => {
it('supersedes an older all-messages revision and cannot commit an ignored abort', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const firstResult = deferred()
const requests: SessionTitleProviderRequest[] = []
@@ -264,6 +335,8 @@ describe('SessionTitleService Provider lifecycle', () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, CONFIG)
const requests: SessionTitleProviderRequest[] = []
ctx.sessionTitle.register({
@@ -318,6 +391,8 @@ describe('SessionTitleService Provider lifecycle', () => {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({
title: 'Unexpected title',
@@ -349,6 +424,7 @@ describe('SessionTitleService Provider lifecycle', () => {
it('contains automatic failures but lets explicit refresh reject', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const provider: SessionTitleProvider = {
diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts
index 9452ee8dc4..7701d953b7 100644
--- a/packages/session/session-title/tests/rename.spec.ts
+++ b/packages/session/session-title/tests/rename.spec.ts
@@ -5,6 +5,7 @@ import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionTitleService, {
SessionTitleProviderId,
foldSessionTitle,
@@ -32,6 +33,7 @@ describe('SessionTitleService.rename', () => {
it('appends a normalized user-source title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-accept'))
session.append('turn/start', { turn: 1 })
@@ -57,6 +59,7 @@ describe('SessionTitleService.rename', () => {
it('rejects titles that normalize to empty and dead sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-reject'))
expect(() => ctx.sessionTitle.rename(session, ' [31m ')).toThrow(/visible characters/)
@@ -68,6 +71,7 @@ describe('SessionTitleService.rename', () => {
it('pins the title: later user messages schedule no automatic revision; refresh unpins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const generate = vi.fn(async (request: SessionTitleProviderRequest) => ({
title: 'Provider title',
@@ -105,6 +109,7 @@ describe('SessionTitleService.rename', () => {
it('fallback-only refresh also unpins: the user title yields to a re-derived fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('rename-unpin-fallback'))
session.append('turn/start', { turn: 1 })
@@ -126,6 +131,7 @@ describe('SessionTitleService.rename', () => {
it('supersedes in-flight automatic generation: a late provider result cannot override the user title', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
// The provider parks on a test-held deferred so rename lands while its
// generation is ACTIVE (not merely scheduled).
@@ -165,6 +171,7 @@ describe('SessionTitleService.rename', () => {
it('fallback-only refresh keeps the user title when no fallback is derivable', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
// A 3-byte fallback cap cannot hold the 4-byte emoji prompt: the
// re-derived fallback is empty, so the pinned title survives the refresh.
await ctx.plugin(SessionTitleService, { ...CONFIG, fallbackMaxBytes: 3 })
diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts
index 521562c4ed..6ac579bd35 100644
--- a/packages/session/session-title/tests/service-contracts.spec.ts
+++ b/packages/session/session-title/tests/service-contracts.spec.ts
@@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { Context, type Fiber } from '@deepseek-ai/cordis'
import { describe, expect, it, vi } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionTitleService, {
SessionTitleProviderId,
type Config,
@@ -29,6 +30,7 @@ async function settle(): Promise {
async function setup(config: Config = CONFIG): Promise {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, config)
return ctx
}
@@ -241,6 +243,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
it('cancels a queued fallback when the session-title service unloads', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const lifecycle: { fiber?: Fiber; session?: Session; inactiveRefresh?: Promise } = {}
ctx.on('internal/plugin', (subject) => {
if (subject !== lifecycle.fiber || subject.uid !== null || lifecycle.session === undefined) return
@@ -273,6 +276,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
it('suppresses a queued fallback failure after service unload begins', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const session = startSession(ctx, 'service-unload-started-fallback')
@@ -288,6 +292,7 @@ describe('SessionTitleService configuration and refresh boundaries', () => {
it('aborts pending and active provider work and drains ignored cancellation during service unload', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(SessionTitleService, CONFIG)
const result = deferred()
const requests: SessionTitleProviderRequest[] = []
diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts
index bb8ce4625d..905e24aa9b 100644
--- a/packages/session/session-title/tests/session-title.spec.ts
+++ b/packages/session/session-title/tests/session-title.spec.ts
@@ -2,6 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { Context } from '@deepseek-ai/cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionTitleService, {
SessionTitleProviderId,
fallbackSessionTitle,
@@ -39,6 +40,7 @@ describe('SessionTitleService', () => {
it('logs and folds an immediate fallback after the first eligible human text message', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('fresh'))
session.append('turn/start', {
@@ -75,6 +77,7 @@ describe('SessionTitleService', () => {
it('derives a fallback title from the direct prompt instead of baked prefix context', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('prefixed-title'))
session.append('turn/start', {
@@ -93,6 +96,7 @@ describe('SessionTitleService', () => {
it('waits through synthetic, empty, and non-text messages, then keeps the first fallback', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SessionTitleService, CONFIG)
const session = ctx.sessions.create(SessionId('eligibility'))
session.append('turn/start', {
@@ -159,4 +163,10 @@ describe('SessionTitleService', () => {
updatedAt: seed.events[1]?.time,
})
})
+
+ it('folds an empty or title-less log to undefined', () => {
+ expect(foldSessionTitle([])).toBeUndefined()
+ const empty = Session.create(SessionId('no-title'))
+ expect(foldSessionTitle(empty.events)).toBeUndefined()
+ })
})
diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json
index 40937098cb..d4eef17de4 100644
--- a/packages/session/session-title/tsconfig.json
+++ b/packages/session/session-title/tsconfig.json
@@ -31,6 +31,10 @@
},
{
"path": "../session-projection"
+ },
+ {
+ "path": "../../core/agent"
+
}
]
}
diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json
index e0614889ac..f7aa203673 100644
--- a/packages/shell/bash-sandbox/package.json
+++ b/packages/shell/bash-sandbox/package.json
@@ -48,6 +48,7 @@
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
- "@deepseek-ai/node-addon-landlock-run": "workspace:^"
+ "@deepseek-ai/node-addon-landlock-run": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts
index 11f8a17272..5b00eb14ac 100644
--- a/packages/shell/bash-sandbox/tests/bwrap.e2e.ts
+++ b/packages/shell/bash-sandbox/tests/bwrap.e2e.ts
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -42,6 +43,7 @@ async function tempDir(base: string): Promise {
async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-write'): Promise {
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
diff --git a/packages/shell/bash-sandbox/tests/landlock.e2e.ts b/packages/shell/bash-sandbox/tests/landlock.e2e.ts
index a1f2b3ada7..0160caaa56 100644
--- a/packages/shell/bash-sandbox/tests/landlock.e2e.ts
+++ b/packages/shell/bash-sandbox/tests/landlock.e2e.ts
@@ -8,6 +8,7 @@ import { Context } from '@deepseek-ai/cordis'
import { launcherPath } from '@deepseek-ai/node-addon-landlock-run'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -47,6 +48,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false }
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
diff --git a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
index dea6a41ec1..d665bb4eb5 100644
--- a/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
+++ b/packages/shell/bash-sandbox/tests/partial-landlock.spec.ts
@@ -9,6 +9,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run'
import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
@@ -51,6 +52,7 @@ ${fatalBranch}exec "$@"
async function setup(fatalExit?: number): Promise {
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(LocalSandboxProvider, {})
const sandbox = ctx.sandbox as LocalSandboxProvider
sandbox.internals = {
@@ -68,6 +70,7 @@ async function setup(fatalExit?: number): Promise {
async function setupConfiguredRunner(runner: string): Promise {
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(LocalSandboxProvider, {
runnerCommand: [runner],
runnerFailureSignatures: ['configured-runner: fatal'],
diff --git a/packages/shell/bash-sandbox/tests/sandbox.spec.ts b/packages/shell/bash-sandbox/tests/sandbox.spec.ts
index 750fb870c3..4029cc4f40 100644
--- a/packages/shell/bash-sandbox/tests/sandbox.spec.ts
+++ b/packages/shell/bash-sandbox/tests/sandbox.spec.ts
@@ -11,6 +11,7 @@ import { join, resolve } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { ShellRunResult, CollectedOutput } from '@deepseek-ai/dsh-shell'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
@@ -62,6 +63,7 @@ async function setup(
}
}
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxPolicyService, {
...mode !== undefined ? { mode } : {},
diff --git a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
index 136259468c..7f2a1be03f 100644
--- a/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
+++ b/packages/shell/bash-sandbox/tests/seatbelt.e2e.ts
@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts'
import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
@@ -41,6 +42,7 @@ async function sandboxedBash(workspace: string, mode: 'read-only' | 'workspace-w
ctx = new Context()
await ctx.plugin(LocalSandboxProvider, {})
;(ctx.sandbox as LocalSandboxProvider).internals = { probeBwrap: () => false, probeLandlock: () => 'unusable' }
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: workspace })
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(SandboxBashExecutor, { cwd: workspace, timeoutMs: 30_000 })
diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json
index 65a3863bec..dc40592bff 100644
--- a/packages/shell/pwsh-sandbox/package.json
+++ b/packages/shell/pwsh-sandbox/package.json
@@ -46,6 +46,7 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
diff --git a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
index 22d65b60a9..258e3d950f 100644
--- a/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
+++ b/packages/shell/pwsh-sandbox/tests/sandbox.spec.ts
@@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { SandboxPwshExecutor } from '../src/index.ts'
@@ -64,6 +65,7 @@ async function setup(
}
}
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(FakeSandboxProvider)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: spillDir })
await ctx.plugin(subprocess)
diff --git a/packages/shell/shell/README.i18n.yaml b/packages/shell/shell/README.i18n.yaml
index a4e67f3760..013a9e13cb 100644
--- a/packages/shell/shell/README.i18n.yaml
+++ b/packages/shell/shell/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/shell/shell/README.md
-README.md: a15980ead797a73c18dd6d8825a4b38cf845df60
-README.zh.md: 87a7dffdfb28fcf8a58e6254bf450e513dd458f4
+README.md: 6a7c88bb0ad2612b239d68f79c80351a4316c340
+README.zh.md: 476bd793828b068905aec65d1072a9d78c0fa725
diff --git a/packages/shell/shell/README.md b/packages/shell/shell/README.md
index a15980ead7..6a7c88bb0a 100644
--- a/packages/shell/shell/README.md
+++ b/packages/shell/shell/README.md
@@ -33,7 +33,7 @@ Implementations subclass `ShellExecutor` and implement the abstract methods. Dis
`ShellExecRequest` (command, workdir?, timeoutMs?, stdoutMaxBytes?, signal?, stdin?, env?, dshEnv?, sandboxPolicy?) resolves to `ShellExecSpec` (command, workdir, timeoutMs, stdoutMaxBytes, signal?, stdin?, env?, dshEnv?, sandboxPolicy) before execution. `stdoutMaxBytes` is a trusted foreground-run capture budget for consumers that must parse complete bounded stdout; the model-facing bash tool does not expose it. `sandboxPolicy` is optional on the request and required-but-nullable on the resolved spec: it carries the complete per-call mode and workspace root. The sandbox tool path resolves it from the calling session through `ctx.sandboxPolicy`; a direct sandbox-executor caller falls back to deployment policy, while a non-sandboxing executor carries the field and confines nothing.
-The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md).
+The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `sandboxMode` projection, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `ShellRunResult`; `start()` returns `ShellProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `ShellSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/shell.md](../../../docs/subsystems/shell.md).
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.shellEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
diff --git a/packages/shell/shell/README.zh.md b/packages/shell/shell/README.zh.md
index 87a7dffdfb..476bd79382 100644
--- a/packages/shell/shell/README.zh.md
+++ b/packages/shell/shell/README.zh.md
@@ -33,7 +33,7 @@
`ShellExecRequest`(command、workdir?、timeoutMs?、stdoutMaxBytes?、signal?、stdin?、env?、dshEnv?、sandboxPolicy?)在执行前解析为 `ShellExecSpec`(command、workdir、timeoutMs、stdoutMaxBytes、signal?、stdin?、env?、dshEnv?、sandboxPolicy)。`stdoutMaxBytes` 是受信任前台运行的捕获预算,用于必须解析完整有界 stdout 的消费方;面向模型的 bash 工具不公开该字段。`sandboxPolicy` 在请求上可选,在已解析 spec 上必填但可为 null:它携带完整的每次调用模式与工作区根目录。沙箱工具路径通过 `ctx.sandboxPolicy` 从调用会话解析它;沙箱执行器的直接调用方回退到部署策略,非沙箱执行器则携带该字段但不作限制。
-每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。
+每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`sandboxMode` 投影以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `ShellRunResult`;`start()` 返回 `ShellProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `ShellSandboxInfo`。详见 `src/types.ts` 与 [subsystems/shell.md](../../../docs/subsystems/shell.md)。
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.shellEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json
index 2232d909e4..c6d671d40f 100644
--- a/packages/shell/tool-bash-persistent/package.json
+++ b/packages/shell/tool-bash-persistent/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
index 6d6affdb44..878930426c 100644
--- a/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
+++ b/packages/shell/tool-bash-persistent/tests/loader-composition.spec.ts
@@ -15,6 +15,7 @@ import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
@@ -75,6 +76,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-terminal'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
@@ -108,6 +110,7 @@ suite('persistent Bash through a real cordis.yml Loader composition', () => {
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json
index 0de6feef49..78244ea09b 100644
--- a/packages/shell/tool-bash/package.json
+++ b/packages/shell/tool-bash/package.json
@@ -68,6 +68,7 @@
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/shell/tool-bash/tests/integration.spec.ts b/packages/shell/tool-bash/tests/integration.spec.ts
index 78a40d6962..c645230ac2 100644
--- a/packages/shell/tool-bash/tests/integration.spec.ts
+++ b/packages/shell/tool-bash/tests/integration.spec.ts
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
@@ -26,6 +27,8 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
async function harness(adapter: MockAdapter, sessionRoot?: string, dshHome?: string) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ // AgentLoop declares the registry as a required injection.
+ await ctx.plugin(SessionProjectionRegistry)
if (sessionRoot !== undefined) {
await ctx.plugin(JsonlSessionPersistence, { root: sessionRoot, compression: 'none' })
}
diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts
index 0f2e4d5a20..acc07a9fcd 100644
--- a/packages/shell/tool-bash/tests/tools.spec.ts
+++ b/packages/shell/tool-bash/tests/tools.spec.ts
@@ -10,6 +10,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
@@ -19,6 +20,7 @@ import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
import { processOutcome } from '../src/background.ts'
@@ -188,6 +190,8 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
+ await ctx.plugin(SessionProjectionRegistry)
+ ctx.sessionProjections.register(turnBoundaryProjectionDefinition)
await ctx.plugin(SandboxPolicyService, {})
await ctx.plugin(RecordingSandboxExecutor)
if (withApproval) await ctx.plugin(ApprovalService)
@@ -201,7 +205,7 @@ function sandboxAgent(
ctx?: Context,
onAppend?: (type: string) => void,
): Agent {
- const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }]
+ const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start', data: { turn: 1 } }]
if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
const id = SessionId('sandbox-session')
return {
diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json
index e87a546b62..afc3e4a412 100644
--- a/packages/shell/tool-pwsh/package.json
+++ b/packages/shell/tool-pwsh/package.json
@@ -50,6 +50,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
+ "@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-shell": "workspace:^",
"@deepseek-ai/dsh-shell-env": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
@@ -58,6 +59,7 @@
"@deepseek-ai/dsh-pwsh-local": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts
index ecd9f5bde5..9d007dd53f 100644
--- a/packages/shell/tool-pwsh/tests/tools.spec.ts
+++ b/packages/shell/tool-pwsh/tests/tools.spec.ts
@@ -27,6 +27,8 @@ import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import * as ToolPwsh from '@deepseek-ai/dsh-tool-pwsh'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
@@ -210,6 +212,11 @@ async function setupSandboxed(withApproval = false) {
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(ToolTasks)
await ctx.plugin(BashEnvPlugin)
+ await ctx.plugin(SessionProjectionRegistry)
+ // 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.plugin(SandboxPolicyService, {})
await ctx.plugin(ConfiningFakeBash)
if (withApproval) await ctx.plugin(ApprovalService)
@@ -229,8 +236,10 @@ function sandboxAgent(
ctx?: Context,
onAppend?: (type: string) => void,
): Agent {
- const events: Array<{ type: string; data?: Record }> = [{ type: 'turn/start' }]
- if (mode !== undefined) events.push({ type: 'sandbox/mode', data: { mode } })
+ const events: Array<{ type: string; data?: Record; seq?: number }> = [
+ { type: 'turn/start', seq: 0, data: { turn: 1 } },
+ ]
+ if (mode !== undefined) events.push({ type: 'sandbox/mode', seq: 1, data: { mode } })
const id = SessionId('sandbox-session')
return {
id,
diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json
index cdd1bbfa04..a7cb45ceb3 100644
--- a/packages/skill/tool-skill/package.json
+++ b/packages/skill/tool-skill/package.json
@@ -37,10 +37,12 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
- "@deepseek-ai/schemastery": "workspace:^"
+ "@deepseek-ai/schemastery": "workspace:^",
+ "zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -51,6 +53,7 @@
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-filesystem": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts
index e2a0cc2dca..ac1bbec5fa 100644
--- a/packages/skill/tool-skill/src/index.ts
+++ b/packages/skill/tool-skill/src/index.ts
@@ -7,10 +7,12 @@
import { createHash } from 'node:crypto'
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
+import { z as zod } from 'zod'
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { UserMessage } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-projection'
import {
escapeText,
isModelInvocable,
@@ -22,7 +24,7 @@ import {
} from '@deepseek-ai/dsh-skill'
export const name = 'tool-skill'
-export const inject = ['agents', 'tools', 'skills']
+export const inject = ['agents', 'tools', 'skills', 'sessionProjections']
const DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH = 500
/**
@@ -68,12 +70,30 @@ export const Config: z = z.object({
catalogDescriptionMaxLength: z.number().default(DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH),
})
+/**
+ * The skill-catalog projection's state schema — the one definition of the
+ * state shape; the type is inferred from it (state equals the public shape).
+ */
+const skillCatalogStateSchema = zod.object({
+ digest: zod.string().min(1),
+ seq: zod.number().int().nonnegative(),
+}).nullable()
+
+type SkillCatalogState = zod.infer
+
/**
* Register the model-facing skill loader and its visibility-matched
* durable session catalog. The catalog is emitted only when the calling agent
* resolves this plugin's exact tool registration; a restriction or scoped
* same-name shadow therefore removes both the schema and its call guidance.
*/
+declare module '@deepseek-ai/dsh-session-projection/types' {
+ interface SessionProjectionStateMap {
+ /** The latest published skill-catalog message (digest + seq); null before the first publication. */
+ skillCatalog: SkillCatalogState
+ }
+}
+
export function apply(ctx: Context, config: Config = {}): void {
const catalogDescriptionMaxLength = config.catalogDescriptionMaxLength ?? DEFAULT_CATALOG_DESCRIPTION_MAX_LENGTH
assertPositiveInteger('catalogDescriptionMaxLength', catalogDescriptionMaxLength, 3)
@@ -203,6 +223,19 @@ export function apply(ctx: Context, config: Config = {}): void {
return { kind: 'enter', messages: [...decision.messages, ...injections] }
})
+ ctx.sessionProjections.register({
+ key: 'skillCatalog',
+ stateVersion: 1,
+ stateSchema: skillCatalogStateSchema,
+ init: () => null,
+ apply: (state, event) => {
+ if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') return state
+ const entries = readCatalogEntries(event.data.source)
+ if (entries === undefined) return state
+ return { digest: digestCatalogEntries(entries), seq: event.seq }
+ },
+ })
+
// Register after the tool so reverse teardown removes guidance first. Exact definition
// identity prevents a scoped shadow merely named `skill` from inheriting this catalog.
//
@@ -226,7 +259,7 @@ export function apply(ctx: Context, config: Config = {}): void {
const skills = snapshot.skills.filter(isModelInvocable)
const entries = catalogSourceEntries(skills, catalogDescriptionMaxLength)
const digest = digestCatalogEntries(entries)
- const history = catalogHistory(agent)
+ const history = catalogHistory(ctx, agent)
const existing = catalogMessage(decision.messages)
if (history.visibleDigest === digest) {
return existing === undefined
@@ -358,22 +391,13 @@ function readCatalogEntries(source: unknown): SkillCatalogSource['entries'] | un
return readable
}
-function catalogHistory(agent: Agent): { visibleDigest?: string; published: boolean } {
+function catalogHistory(ctx: Context, agent: Agent): { visibleDigest?: string; published: boolean } {
const visible = new Set(agent.session.surface.nodes)
- const events = agent.session.events
- let published = false
- for (let index = events.length - 1; index >= 0; index -= 1) {
- // The loop bounds prove the read-only event view contains this index.
- // oxlint-disable-next-line typescript/no-non-null-assertion
- const event = events[index]!
- if (event.type !== 'user/message' || event.data.source.kind !== 'skill-catalog') continue
- const entries = readCatalogEntries(event.data.source)
- if (entries === undefined) continue
- const digest = digestCatalogEntries(entries)
- published = true
- if (visible.has(event.seq)) return { visibleDigest: digest, published }
- }
- return { published }
+ const state = ctx.sessionProjections.stateOf(agent.session, 'skillCatalog') ?? null
+ if (state === null) return { published: false }
+ return visible.has(state.seq)
+ ? { visibleDigest: state.digest, published: true }
+ : { published: true }
}
function catalogMessage(
diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts
index d8dd9df2f4..4b63ba8207 100644
--- a/packages/skill/tool-skill/tests/tool-skill.spec.ts
+++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts
@@ -9,6 +9,7 @@ import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepse
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { agentEvents, Inbox, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SkillRegistry from '@deepseek-ai/dsh-skill'
import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
@@ -30,6 +31,7 @@ async function setup(home: string, config: toolSkill.Config = {}): Promise {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
const home = await tempDir('tool-schema')
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
@@ -751,6 +754,7 @@ describe('dsh-tool-skill', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
await ctx.plugin(AgentRegistry)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json
index 8a6fba06e7..f2654b3c9e 100644
--- a/packages/subagent/subagent-acp/package.json
+++ b/packages/subagent/subagent-acp/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
index 9654df5feb..607350a29c 100644
--- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
+++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke'
import * as acp from '../src/index.ts'
@@ -52,6 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
it('drives the real acp-agent example process to answer a prompt', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -82,6 +84,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
it('drives the child to do real file work via its own bash tool', async () => {
workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-'))
ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
index c534b8e949..fd66d9368f 100644
--- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
+++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts
@@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type { SubprocessOutcome } from '@deepseek-ai/dsh-subprocess'
@@ -43,6 +44,7 @@ interface SetupEnv {
*/
async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -222,6 +224,7 @@ describe('cwd resolution', () => {
const sentinel = join(tmp, 'spawned')
try {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
// A command that would create the sentinel if the child were ever spawned.
@@ -241,6 +244,7 @@ describe('cwd resolution', () => {
const parentDir = realpathSync(mkdtempSync(join(tmpdir(), 'acp-parent-cwd-')))
try {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -269,6 +273,7 @@ describe('cwd resolution', () => {
const relative = 'packages/subagent/subagent-acp'
const absolute = resolve(relative)
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -289,6 +294,7 @@ describe('cwd resolution', () => {
// `path.resolve('')` is the process cwd, so an empty string would silently
// reintroduce the launch-directory fallback this resolution removed.
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await expect(ctx.plugin(acp, {
@@ -310,6 +316,7 @@ describe('cwd resolution', () => {
chmodSync(tmp, 0o600)
try {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await expect(ctx.plugin(acp, {
@@ -329,6 +336,7 @@ describe('cwd resolution', () => {
it('rejects a config cwd that is not an accessible directory at load', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await expect(ctx.plugin(acp, {
@@ -371,6 +379,7 @@ describe('cwd resolution', () => {
const sentinel = join(tmp, 'spawned')
try {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, { providerName: 'acp', command: 'touch', args: [sentinel], permission: 'reject', env: {} })
@@ -694,6 +703,7 @@ describe('dsh-subagent-acp', () => {
const ready = join(tmp, 'trap-armed')
try {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -727,6 +737,7 @@ describe('dsh-subagent-acp', () => {
{ disposeGraceMs: MAX_TIMER_DELAY_MS + 1 },
]) {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad }))
@@ -737,6 +748,7 @@ describe('dsh-subagent-acp', () => {
it('rejects a startup failure via the provider load path', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
await ctx.plugin(acp, {
@@ -868,6 +880,7 @@ describe('dsh-subagent-acp', () => {
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} })
diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json
index fe974b0344..5bffd3de08 100644
--- a/packages/subagent/subagent-claude-code/package.json
+++ b/packages/subagent/subagent-claude-code/package.json
@@ -63,6 +63,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts
index 1881e14d33..d9a5c5bc1b 100644
--- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts
+++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts
@@ -14,6 +14,7 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
@@ -112,6 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
}
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const handles: SubprocessHandle[] = []
diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts
index a8e38885ef..ec227e6f4d 100644
--- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts
+++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts
@@ -21,6 +21,7 @@ import { Context } from '@deepseek-ai/cordis'
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as claudeCode from '../src/index.ts'
@@ -180,6 +181,7 @@ interface RealRuntime {
async function realRuntime(): Promise {
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const handles: SubprocessHandle[] = []
diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts
index 8f1ad34d8a..5eedbc0051 100644
--- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts
+++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts
@@ -26,6 +26,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type {
SubprocessHandle,
SubprocessOutcome,
@@ -390,6 +391,7 @@ describe('task admission and package contracts', () => {
it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(claudeCode, {})
@@ -421,6 +423,7 @@ describe('task admission and package contracts', () => {
it('keeps named instances, runs, and HMR ownership isolated', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const safeChild = fakeChild()
@@ -518,6 +521,7 @@ describe('task admission and package contracts', () => {
it('rejects duplicate provider names without replacing the first instance', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const firstFiber = await ctx.plugin(claudeCode, {
@@ -552,6 +556,7 @@ describe('task admission and package contracts', () => {
it('resolves the safe permission default when apply is called directly', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
claudeCode.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
@@ -561,6 +566,7 @@ describe('task admission and package contracts', () => {
it('starts through the registered provider with its resolved config and diagnostics', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const child = fakeChild()
diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json
index dbe30e18e1..2ded5fdc95 100644
--- a/packages/subagent/subagent-codex/package.json
+++ b/packages/subagent/subagent-codex/package.json
@@ -62,6 +62,7 @@
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts
index c12e96a306..788ea54cde 100644
--- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts
+++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts
@@ -15,6 +15,7 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
@@ -96,6 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)(
}
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const handles: SubprocessHandle[] = []
diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts
index 3b95de4f79..459c09d845 100644
--- a/packages/subagent/subagent-codex/tests/real-product.spec.ts
+++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts
@@ -17,6 +17,7 @@ import { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as codex from '../src/index.ts'
@@ -120,6 +121,7 @@ interface RealRuntime {
async function realRuntime(): Promise {
const ctx = new Context()
contexts.push(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const handles: SubprocessHandle[] = []
diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
index 6ba6f65e87..236db16e60 100644
--- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
+++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts
@@ -10,6 +10,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
@@ -399,6 +400,7 @@ describe('task admission and package contracts', () => {
it('registers the default descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(codex, {})
@@ -428,6 +430,7 @@ describe('task admission and package contracts', () => {
it('keeps named instances, runs, and HMR ownership isolated', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const safeChild = fakeChild()
@@ -524,6 +527,7 @@ describe('task admission and package contracts', () => {
it('rejects duplicate provider names without replacing the first instance', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const firstFiber = await ctx.plugin(codex, {
@@ -556,6 +560,7 @@ describe('task admission and package contracts', () => {
it('resolves the safe permission default when apply is called directly', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
codex.apply(ctx, { env: {}, disposeGraceMs: 3_000 })
@@ -601,6 +606,7 @@ describe('task admission and package contracts', () => {
it('requires a parent session cwd without suggesting unsupported config', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
@@ -1796,6 +1802,7 @@ describe('run lifecycle and quiescence', () => {
it('uses the registered provider config and logs flattened errors', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const child = fakeChild()
diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json
index 8db10499cd..ad728161d6 100644
--- a/packages/subagent/subagent-dsh-sdk/package.json
+++ b/packages/subagent/subagent-dsh-sdk/package.json
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
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 f4b5bd4267..5314e5da4c 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
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Agent } from '@deepseek-ai/dsh-agent'
import * as sdk from '../src/index.ts'
import {
@@ -36,6 +37,7 @@ function request(text = 'p', signal = new AbortController().signal) {
/** Mount the SDK backend pointed at the fake runtime, scripted by `fakeEnv`. */
async function setup(fakeEnv: Record = {}, config: Partial = {}) {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
// The Config type models the post-validation shape, so the default registry
// name is stated here; the Loader-composition fixture omits providerName and
@@ -361,6 +363,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
it('registers under the configured provider name and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(sdk, {
providerName: 'sdk-hmr',
@@ -385,6 +388,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
it('rejects non-positive timing bounds at load', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const base = { providerName: 'sdk', command: 'true', args: [], provider: 'p', model: 'm', env: {} }
await expect(ctx.plugin(sdk, { ...base, shutdownTimeoutMs: 0 })).rejects.toThrow('shutdownTimeoutMs must be a positive finite number')
@@ -397,6 +401,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
'rejects invalid maxTokens %s at load',
async (maxTokens) => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
@@ -415,6 +420,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
'defensively rejects invalid maxTokens %s when apply is called directly',
async (maxTokens) => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
expect(() => { sdk.apply(ctx, {
providerName: 'sdk',
@@ -434,6 +440,7 @@ describe('dsh-subagent-dsh-sdk provider', () => {
it('rejects an empty config cwd at load', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await expect(ctx.plugin(sdk, {
providerName: 'sdk',
diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json
index f5f0a8b1e6..1e383410a3 100644
--- a/packages/subagent/subagent-fork-in-process/package.json
+++ b/packages/subagent/subagent-fork-in-process/package.json
@@ -53,6 +53,7 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
index 42e5216e47..f14c5d001b 100644
--- a/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
+++ b/packages/subagent/subagent-fork-in-process/tests/multi-subagent.spec.ts
@@ -9,6 +9,7 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as fork from '../src/index.ts'
@@ -37,6 +38,7 @@ async function setup(script: Script) {
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(Spawn, { providerName: 'spawn' })
await ctx.plugin(fork, { providerName: 'fork' })
diff --git a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
index 6d763b7c0a..46c60498a3 100644
--- a/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
+++ b/packages/subagent/subagent-fork-in-process/tests/subagent-fork-in-process.spec.ts
@@ -11,6 +11,7 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import * as fork from '../src/index.ts'
@@ -44,6 +45,7 @@ async function setup(script: Script) {
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(fork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
@@ -201,6 +203,7 @@ describe('dsh-subagent-fork-in-process', () => {
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(fork, { providerName: 'fork' })
diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json
index 613eea85ca..593451bf05 100644
--- a/packages/subagent/subagent-in-process-driver/package.json
+++ b/packages/subagent/subagent-in-process-driver/package.json
@@ -58,6 +58,7 @@
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
index aa7437be81..4ff72cba21 100644
--- a/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
+++ b/packages/subagent/subagent-in-process-driver/tests/inheritance.spec.ts
@@ -18,6 +18,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -40,6 +41,7 @@ async function setupWalled(script: Script): Promise<{ ctx: Context; parent: Agen
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace })
await ctx.plugin(SandboxedFileSystem, { cwd: workspace })
await ctx.plugin(ToolFs)
diff --git a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
index b4c5d5736e..93a141375d 100644
--- a/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
+++ b/packages/subagent/subagent-in-process-driver/tests/preset-inheritance.spec.ts
@@ -18,6 +18,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import AgentPresets from '@deepseek-ai/dsh-agent-presets'
import { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -39,6 +40,7 @@ async function setupPresetHost(): Promise<{ ctx: Context; adapter: MockAdapter;
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(AgentPresets, { default: 'coding', roots: ROOTS, includeUserRoot: false })
const adapter = new MockAdapter([textResponse('parent idle'), textResponse('child done')])
diff --git a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
index 8840e8b101..edc4e38846 100644
--- a/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
+++ b/packages/subagent/subagent-in-process-driver/tests/structured.spec.ts
@@ -12,6 +12,7 @@ import SubagentRuntime, {
type ResolvedSubagentStartRequest,
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { defineContentToolFixture, RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -67,6 +68,7 @@ async function setup(script: Script, options: SetupOptions = {}) {
}
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const disposeProvider = ctx.subagents.registerProvider({
name: 'spawn',
diff --git a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
index 823890d934..da123ca552 100644
--- a/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
+++ b/packages/subagent/subagent-in-process-driver/tests/subagent-in-process-driver.spec.ts
@@ -10,6 +10,7 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -28,6 +29,7 @@ async function setup(script: Script, parentOptions: Partial = {})
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const adapter = new MockAdapter(script)
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json
index 2f317dde8a..a3843f8ff3 100644
--- a/packages/subagent/subagent-spawn-in-process/package.json
+++ b/packages/subagent/subagent-spawn-in-process/package.json
@@ -55,6 +55,7 @@
"@deepseek-ai/dsh-subagent-in-process-driver": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/subagent-spawn-in-process/tests/harness.ts b/packages/subagent/subagent-spawn-in-process/tests/harness.ts
index d60d8057d3..16e6680432 100644
--- a/packages/subagent/subagent-spawn-in-process/tests/harness.ts
+++ b/packages/subagent/subagent-spawn-in-process/tests/harness.ts
@@ -1,6 +1,7 @@
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
@@ -24,6 +25,7 @@ export async function spawnHarness(workdir: string): Promise {
// spawned children render it. It stays neutral for both roles; the
// delegation nudge lives in the e2e's user prompt and the subagent tool's
// own description.
+ await ctx.plugin(SessionProjectionRegistry)
await mountAgentLoopTestDependencies(ctx, {
systemPrompt: { persona: 'You are a coding agent. Report only when the requested work is done.' },
})
diff --git a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
index 74aefb4ebd..3427a7bf80 100644
--- a/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
+++ b/packages/subagent/subagent-spawn-in-process/tests/subagent-spawn-in-process.spec.ts
@@ -11,6 +11,7 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import * as spawn from '../src/index.ts'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
@@ -38,6 +39,7 @@ async function setup(script: Script) {
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -290,6 +292,7 @@ describe('dsh-subagent-spawn-in-process', () => {
it('unregisters the provider when its fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(AgentRegistry)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
@@ -322,6 +325,7 @@ describe('dsh-subagent-spawn-in-process', () => {
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
ctx.llm.registerAdapter(['mock'], adapter)
@@ -350,6 +354,7 @@ describe('dsh-subagent-spawn-in-process', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(spawn, { providerName: 'spawn' })
const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml
index d140507b44..e5024850ef 100644
--- a/packages/subagent/subagent/README.i18n.yaml
+++ b/packages/subagent/subagent/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/subagent/subagent/README.md
-README.md: 99b28fb7fc62473fa02fef4f3977f29e9935edaa
-README.zh.md: 6970c8f21424cb73d170602c52fb98641d5d712d
+README.md: 7fb8bc9fe7ffa9732b4ae793c43010066e81325f
+README.zh.md: f6c4aef102b0771d981d383d238f4bf4733446eb
diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md
index 99b28fb7fc..7fb8bc9fe7 100644
--- a/packages/subagent/subagent/README.md
+++ b/packages/subagent/subagent/README.md
@@ -97,13 +97,13 @@ Provider additions and removals also emit `subagent/provider-added` and `subagen
Continuable children do not create `SubagentRun` or Jobs. The continuation manager directly owns one process-local Activation and retained `AgentHandle` per resident child Session, uses the Agent inbox as the only FIFO, and cold-resumes from the durable descriptor. Exact live direct-parent identity authorizes parent-to-child delivery. Exact live child identity authorizes reports; the manager derives the recipient from durable `parentSession`, and `MessageSource` records the sender without granting authority. Interrupt authority is deliberately wider than delivery authority: a human presents the durable direct-parent address so a live child stays stoppable while its parent Agent is offline, and any exact live ancestor recorded in the Activation's materialization lineage may stop its descendant, because stopping a turn is idempotent and delivers no content.
-When `ctx.sessionProjections` is available, the service registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
+The service requires `ctx.sessionProjections` and registers two projection units. `subagentTiming` resets at each descriptor so a fork seed's ancestor work cannot enter the child's total, then accumulates `turn/start` → `turn/end` active time and retains same-cut `active.since` and `active.through` bounds for an open turn; while that turn remains open, `active.through` follows the latest folded event, giving an inactive consumer a conservative crash bound without mixing in newer session metadata. `subagent` folds the durable identity — mode plus creation label — from `subagent/descriptor` events with the same last-wins reset discipline, so a fork seed's ancestor descriptor stands only until the child's own overrides it; a malformed or unrecognized-version payload folds to the serializable `null` sentinel — indistinguishable from a log with no descriptor, and surviving every JSON push frame so a consumer replaces a stale identity instead of keeping it — and never throws.
`registerContinuableSetup()` lets optional packages add child-scoped capabilities without teaching the continuation manager their names. Contributions install synchronously before Activation publication, roll back with failed setup, and are released with the child scope. New grants wait for the next Activation, while contribution removal revokes every resident installation immediately.
## Collection model
-The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: the registry's watermark snapshot for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; an unmounted projection registry fails loud with `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE`, and a missing session store with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
+The model-facing tool collects synchronously by default: it awaits the child result and disposes the run before returning. One-shot background delegation registers a plain Task in the tool, whose generic status, collection, and cancellation tools own later interaction, and persists its model-supplied `description` as the optional display label. Continuable background delegation calls `ctx.subagents.startContinuable()` and returns only the durable child id; the child owns its own turns from inbox acceptance, so there is no Task and no result promise — a caller sends later work with the `send_message` follow-up tool, and `interrupt()` stops only the current turn without disposing the child, and the durable child Session remains the source of the child's detailed output. The continuation manager exists only while `ctx.agents` is available, and session persistence is resolved per continuation operation. Independently, `listChildren()` enumerates the live-preferred merge of the live session store and optional session persistence — live-only when persistence is absent, since a cold child cannot be resumed then either — and serves each child's durable mode/label from the registered `subagent` projection unit: `stateOf()` for a live child; for a cold one, a durable projection-cache row when it serves an own-suffix identity — its `seq` gate proves the value postdates the fork seed, where a child's own descriptor is immutable once appended — else one bounded-concurrency persistence inspection folded through the registry, whose result must still name the enumerated lifecycle (a re-published id degrades to a `corrupt` diagnostic). A throwing cache read renders no verdict — the cache is derived data — and silently falls through to that authoritative re-fold. The projection fold is the single classification authority; listing parses no descriptor itself. A served identity produces a child row; a settled candidate whose fold served no identity is a `corrupt` diagnostic, a failed inspection is a transient `unavailable` retried on the next listing, and a running candidate without an identity yet is omitted (the creation window before its descriptor is appended). It never consults the continuation manager, Agent registrations, Activations, or providers. Each child row derives its read-time `hasChildren` hint from merged headers carrying durable `origin: 'subagent'`; it does not read descendant event logs, and the descriptor-backed child catalog remains authoritative when expanded. Service consumers such as a UI can retain both modes and choose a fallback for an unlabeled one-shot child; the model-facing `list_agents` tool projects only `continuable` entries and refines status through the live Agent registry and maps storage-only to its resumable-not-terminal `ready` (`running`/`idle`/`ready`) and walks `listDescendants()` for its `descendants` scope. The listing forwards the caller's signal to every persistence read, checks cancellation around each of those awaits, and reports every observed abort as `SubagentError` code `CANCELLED`; a missing session store fails with `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE`. See the [background subagent tasks Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable background subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md), the [durable catalog Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md), the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md), the [capability-seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md), and `src/types.ts` for the complete contracts.
Continuable Activations await a best-effort final session flush without treating listener participation as durability confirmation. One-shot runs retain best-effort session checkpointing, so a completed one-shot child is discoverable after disposal only when its session actually reached persistence; the service does not invent a catalog entry from Task history when that checkpoint is absent.
diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md
index 6970c8f214..f6c4aef102 100644
--- a/packages/subagent/subagent/README.zh.md
+++ b/packages/subagent/subagent/README.zh.md
@@ -97,13 +97,13 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委
可继续子级不会创建 `SubagentRun` 或 Task。继续执行管理器为每个驻留子会话直接拥有一个仅存在于当前进程的 Activation 和一个留存的 `AgentHandle`,使用 Agent inbox 作为唯一 FIFO,并从持久化描述符冷恢复。父到子投递由确切在线的直接父级身份授权。上报则由确切在线的子级身份授权;管理器根据持久化的 `parentSession` 推导接收方,`MessageSource` 记录发送方,但不授予权限。中断权限被刻意设计得比投递权限更宽:人类出示持久化直接 parent 地址,因此即使 parent Agent 离线,在线 child 仍可被停止;Activation 物化时记录的任何确切在线 ancestor 也可以停止其后代,因为停止一个轮次是幂等的,且不投递任何内容。
-当 `ctx.sessionProjections` 可用时,服务会注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。
+服务要求存在 `ctx.sessionProjections`,并注册两个投影单元。`subagentTiming` 会在每个描述符处重置,使 fork 种子中的祖先工作不会计入 child 总量,随后累加 `turn/start` → `turn/end` 活跃时间,并为未结束的轮次保留同一切面的 `active.since` 和 `active.through` 边界;在该轮次保持未结束期间,`active.through` 会跟随最近折叠的事件,从而为 inactive 消费方提供保守的崩溃上界,又不会混入更新的会话元数据。`subagent` 以同样的 last-wins 重置纪律从 `subagent/descriptor` 事件折叠持久化身份——模式与创建标签——因此 fork 种子中的祖先描述符只在 child 自身的描述符覆盖之前有效;畸形或版本无法识别的载荷折叠为可序列化的 `null` 哨兵——与没有描述符的日志不可区分,且能完好通过每个 JSON 推送帧,让消费方以之替换掉手中的陈旧身份而非继续保留该身份——绝不抛错。
`registerContinuableSetup()` 允许可选包添加子级作用域能力,而无需让继续执行管理器知道这些能力的名称。贡献会在 Activation 发布前同步安装,在设置失败时一并回滚,并随子级作用域释放。新授权须等到下一个 Activation,移除贡献则会立即撤销每个驻留安装项。
## 收集模型
-面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 取注册表的水位快照;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;投影注册表未挂载则以 `SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE` 响亮失败,会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
+面向模型的工具默认同步收集:先等待子 agent 结果,再 dispose 运行,然后才返回。一次性后台委派会在工具中注册普通 Task,其通用状态、收集和取消工具负责后续交互,并将模型提供的 `description` 持久化为可选显示标签。可继续后台委派会调用 `ctx.subagents.startContinuable()`,只返回持久化子 agent id;子 agent 自 inbox 接受起就拥有自己的轮次,因此没有 Task、也没有结果 promise——调用方通过 `send_message` 后续操作工具发送后续工作,`interrupt()` 只停止当前轮次而不 dispose 子 agent,而持久化子 agent 会话仍是子 agent 详细输出的来源。只有 `ctx.agents` 可用时,继续执行管理器才会存在,而会话持久化按每项继续执行操作解析。与此独立,`listChildren()` 枚举在线会话存储与可选会话持久化的在线优先合并——持久化缺席时仅枚举在线 child,因为那时冷 child 本就无法恢复——并由已注册的 `subagent` 投影单元供给每个 child 的持久化模式与标签:在线 child 通过 `stateOf()` 读取;冷 child 先取可选投影缓存的持久化行,且仅当其 `seq` 门证明该值折叠自 child 自身后缀(fork 种子之后——自有描述符一经追加即不可变)才直接采用,否则经一次有界并发的持久化 inspect 再经注册表折叠,且 inspect 结果必须仍指向枚举时的生命周期(同 id 被重新发布的会话降级为 `corrupt` diagnostic)。缓存读取抛出异常时,不会据此作出分类判断,因为缓存只是派生数据;静默落到该权威重折。分类结果完全以投影折叠为准;列表操作本身不解析描述符。取得身份值即产出 child 行;已定局而折叠未产出身份的候选是 `corrupt` diagnostic,inspect 失败是瞬时的 `unavailable`(下次列表重试),运行中而暂无身份值的候选整行省略(描述符尚未追加的创建窗口)。它不查询继续执行管理器、Agent 注册信息、Activation 或提供方。每个 child 行都会根据合并结果中携带持久化 `origin: 'subagent'` 的 header 派生读取时的 `hasChildren` 提示;它不会读取后代事件日志,展开后仍以描述符支撑的 child 目录为权威依据。UI 等服务消费方可以保留两种模式,并为无标签的一次性 child 选择回退展示;面向模型的 `list_agents` 工具只投影 `continuable` 条目,通过在线 Agent 注册表细化状态,并把仅存于存储的状态映射为可恢复而非终态的 `ready`(`running`/`idle`/`ready`),并在 `descendants` scope 下遍历 `listDescendants()`。列表操作会把调用方的取消信号转发到每次持久化读取,在这些 await 前后检查取消,并将每次检测到的中止报告为 `SubagentError` 错误码 `CANCELLED`;会话存储缺失则以 `SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE` 响亮失败。完整约定见[后台 subagent 任务 Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-21-continuable-background-subagents.md)、[持久化目录 Agent Note](../../../.agents/notes/implemented/feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)、[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)、[能力 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-21-subagent-capability-seam.md)和 `src/types.ts`。
可继续 Activation 会等待 best-effort 的最终会话 flush,但不会把 listener 参与视为持久性确认。一次性运行保留尽力执行的会话检查点,因此已完成的一次性 child 只有在其会话确实进入持久化存储时,才可在 dispose 后继续被发现;如果该检查点缺失,服务不会根据 Task 历史虚构目录条目。
diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts
index 45b96a2311..7e6d7840d1 100644
--- a/packages/subagent/subagent/src/index.ts
+++ b/packages/subagent/subagent/src/index.ts
@@ -169,6 +169,8 @@ declare module '@deepseek-ai/cordis' {
/** Named provider registry with one-shot runs, durable discovery, and continuable-child operations. */
export class SubagentRuntime extends Service {
+ static inject = ['sessionProjections']
+
private providers = new Map()
private continuations: SubagentContinuationManager | undefined
/** Deployment contributions composed into unpublished continuable children. */
@@ -194,10 +196,8 @@ export class SubagentRuntime extends Service {
if (this.continuations === manager) this.continuations = undefined
}, 'subagents.continuationBinding()')
})
- ctx.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition)
- projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition)
- })
+ ctx.sessionProjections.register(subagentTimingProjectionDefinition)
+ ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
}
/**
@@ -334,7 +334,7 @@ export class SubagentRuntime extends Service {
* row when the optional cache serves an own-suffix identity (its `seq`
* gate proves the value postdates the fork seed, where a child's own
* descriptor is immutable once appended), else one persistence inspection
- * folded through the registry. The
+ * folded through the same unit. The
* projection fold is the single classification authority; per-child
* diagnostics relay a fold that served no identity or a failed inspection,
* never a list-time descriptor parse. Absent persistence, enumeration is
@@ -349,8 +349,8 @@ export class SubagentRuntime extends Service {
* @param signal - caller-owned cancellation forwarded to persistence reads
* and observed around every read await.
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
- * @throws {@link SubagentError} when the projection registry or the session
- * store is not mounted, or the caller cancels the listing.
+ * @throws {@link SubagentError} when the session store is not mounted, or
+ * the caller cancels the listing.
*/
listChildren(parentSessionId: SessionId, signal?: AbortSignal): Promise {
return listSubagentChildren(this.ctx, parentSessionId, signal)
diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts
index 95c3be8520..7b45b405bb 100644
--- a/packages/subagent/subagent/src/list-children.ts
+++ b/packages/subagent/subagent/src/list-children.ts
@@ -5,7 +5,7 @@
* mode/label is the registered `subagent` projection unit's value, resolved
* down a three-rung ladder: the registry's watermark cache for a live child,
* a durable projection-cache row when it serves an own-suffix identity (the
- * seq gate), and one persistence inspection folded through the registry
+ * seq gate), and one persistence inspection folded through the same unit
* otherwise, validated against the enumerated lifecycle. The projection fold
* is the single classification authority — this module parses no descriptor
* itself. Absent persistence, enumeration is live-only: a cold child is
@@ -22,6 +22,7 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import { SubagentError } from './error.ts'
+import { subagentIdentityProjectionDefinition } from './projection.ts'
import type { SubagentIdentityProjection } from './projection-types.ts'
/**
@@ -77,11 +78,9 @@ export type SubagentListEntry =
* Why the candidate has no `child` row: `corrupt` for a settled candidate
* whose projection fold served no identity (a missing, malformed, or
* unrecognized-version descriptor — deliberately undistinguished), and
- * for any candidate whose log makes a registered unit's fold or schema
- * throw (deterministic data damage, contained per child); `unavailable`
- * when the candidate's persistence inspection failed (retried on the
- * next listing). `unsupported` is never produced; it remains in the
- * union for consumers that route on it.
+ * `unavailable` when the candidate's persistence inspection failed
+ * (retried on the next listing). `unsupported` is never produced; it
+ * remains in the union for consumers that route on it.
*/
readonly reason: 'corrupt' | 'unsupported' | 'unavailable'
}
@@ -117,19 +116,19 @@ interface PositionedCandidate {
/**
* Enumerate one parent's origin-classified direct children from the
* live-preferred merge of `ctx.sessions` and optional session persistence,
- * serving each identity from the `subagent` projection unit: the registry's
- * watermark snapshot for a live child; for a cold one, a durable
+ * serving each identity from the `subagent` projection unit: `stateOf()` for
+ * a live child; for a cold one, a durable
* projection-cache row when it serves an own-suffix identity (the seq gate),
* else one bounded-concurrency persistence inspection folded through the
- * registry.
+ * same unit.
* @see SubagentRuntime.listChildren for the public cancellation and failure contract.
* @param ctx - context carrying the session store, the projection registry,
* optional persistence, and the optional projection cache.
* @param parentSessionId - parent session whose direct children are listed.
* @param signal - caller-owned cancellation observed around every persistence read.
* @returns children and per-child diagnostics ordered by `createdAt`, then id.
- * @throws {@link SubagentError} when the projection registry or the session
- * store is not mounted, or the caller cancels the listing.
+ * @throws {@link SubagentError} when the session store is not mounted or the
+ * caller cancels the listing.
*/
export async function listChildren(
ctx: Context,
@@ -185,16 +184,7 @@ async function prepareListing(
ctx: Context,
signal: AbortSignal | undefined,
): Promise {
- const projections = ctx.get('sessionProjections')
- // Checked before any read, even with zero candidates: mode/label are the
- // row's strong contract, so a missing fold capability is a deterministic
- // deployment configuration error, never an empty success.
- if (projections === undefined) {
- throw new SubagentError(
- 'listing subagents requires the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
- 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE',
- )
- }
+ const projections = ctx.sessionProjections
// Strict global read, never the `ctx.sessions` property proxy: the proxy is
// caller-scope bound, so a consumer plugin without its own `sessions`
// injection (the model-facing tool, the API proxy) would throw on access.
@@ -257,17 +247,7 @@ async function resolveCandidateRows(
// The registry's watermark cache serves the live value with zero log
// reads; a live child without an identity yet is the creation window
// before the establishing provider appends its descriptor.
- let identity: SubagentIdentityProjection | null | undefined
- try {
- identity = projections.snapshot(candidate.live).values.subagent
- } catch {
- // The snapshot folds EVERY registered unit over this child's log, so
- // any unit's fold or schema can reject damaged payloads. That is
- // deterministic data damage in this one child; it degrades to one
- // corrupt diagnostic instead of failing the whole listing.
- rows[index] = { kind: 'diagnostic', id: childId, reason: 'corrupt' }
- return
- }
+ const identity = projections.stateOf(candidate.live, 'subagent')
// The unit's serializable no-value sentinel is `null`; `undefined` can
// only mean the key was dropped at a JSON boundary. Both are no value.
if (identity === undefined || identity === null) return
@@ -283,7 +263,7 @@ async function resolveCandidateRows(
async () => {
for (let job = queue.shift(); job !== undefined; job = queue.shift()) {
rows[job.index] = await resolveColdIdentity(
- persistence, projections, cache, job.header,
+ persistence, cache, job.header,
subagentParents.has(job.header.id), signal,
)
}
@@ -338,16 +318,13 @@ function compareCorpusRecords(a: CorpusRecord, b: CorpusRecord): number {
/**
* Resolve one cold candidate down the remaining ladder: a durable
* projection-cache row when it serves an own-suffix identity (the seq gate),
- * otherwise one persistence inspection folded through the projection
- * registry (the same detached recipe the API proxy uses for detached session
- * projections). A failed inspection is one transient `unavailable` row
- * retried on the next listing; an inspection naming another lifecycle, and a
- * settled log the fold cannot identify — or that makes any registered unit
- * throw — are final, so they report `corrupt`.
+ * otherwise one persistence inspection folded through the same subagent unit.
+ * A failed inspection is one transient `unavailable` row retried on the next
+ * listing; an inspection naming another lifecycle or a settled log the fold
+ * cannot identify reports `corrupt`.
*/
async function resolveColdIdentity(
persistence: SessionPersistence,
- projections: SessionProjectionRegistry,
cache: SessionProjectionCache | undefined,
header: SessionHeader,
hasChildren: boolean,
@@ -393,16 +370,11 @@ async function resolveColdIdentity(
if (!sameLifecycle(inspected.meta, header)) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
- let identity: SubagentIdentityProjection | null | undefined
- try {
- identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
- } catch {
- // The restore folds EVERY registered unit over this child's log, so any
- // unit's fold or schema can reject damaged payloads — deterministic data
- // damage in this one child, contained as its own corrupt diagnostic.
- return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
+ let identity: SubagentIdentityProjection | null = subagentIdentityProjectionDefinition.init()
+ for (const event of inspected.events) {
+ identity = subagentIdentityProjectionDefinition.apply(identity, event)
}
- if (identity === undefined || identity === null) {
+ if (identity === null) {
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
}
return childRow(childId, identity, 'inactive', hasChildren)
diff --git a/packages/subagent/subagent/src/projection-types.ts b/packages/subagent/subagent/src/projection-types.ts
index 046c32e91d..b07ec8e8a2 100644
--- a/packages/subagent/subagent/src/projection-types.ts
+++ b/packages/subagent/subagent/src/projection-types.ts
@@ -47,9 +47,7 @@ export type SubagentIdentityProjection =
}
declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionMap {
- /** Active-turn duration for a descriptor-backed subagent session. */
- subagentTiming: SubagentTimingProjection
+ interface SessionProjectionStateMap {
/**
* Identity of a descriptor-backed subagent session. `null` ⟺ no valid
* descriptor (missing, malformed, or unrecognized-version — deliberately
@@ -61,4 +59,8 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
*/
subagent: SubagentIdentityProjection | null
}
+ interface SessionProjectionMap {
+ /** Active-turn duration for a descriptor-backed subagent session. */
+ subagentTiming: SubagentTimingProjection
+ }
}
diff --git a/packages/subagent/subagent/src/projection.ts b/packages/subagent/subagent/src/projection.ts
index 288949ac26..d0185144d3 100644
--- a/packages/subagent/subagent/src/projection.ts
+++ b/packages/subagent/subagent/src/projection.ts
@@ -12,42 +12,35 @@ import { foldSubagentDescriptor } from './descriptor.ts'
import type { SubagentDescriptorData } from './descriptor.ts'
import type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts'
-/** Fold state for a subagent's latest timing snapshot. */
-export interface TimingState {
- /** Milliseconds accumulated across completed post-descriptor turns. */
- settledMs: number
- /** Current open interval kept paired inside the fold. */
- active?: { since: number; through: number } | undefined
- /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
- pendingTurnStart?: number | undefined
- /** Whether the fold has crossed a descriptor in this logical log. */
- descriptorSeen: boolean
-}
+const settledMsField = z.number().int().nonnegative()
+
+const activeField = z.object({
+ since: z.number().int().nonnegative(),
+ through: z.number().int().nonnegative(),
+}).strict().optional()
// Zod's optional output includes explicit `undefined`; with
// exactOptionalPropertyTypes the public interface permits omission only.
const projectionSchema = z.object({
- settledMs: z.number().int().nonnegative(),
- active: z.object({
- since: z.number().int().nonnegative(),
- through: z.number().int().nonnegative(),
- }).strict().optional(),
+ settledMs: settledMsField,
+ active: activeField,
}).strict() as unknown as z.ZodType
-const timingStateSchema: z.ZodType = z.object({
- settledMs: z.number().int().nonnegative(),
- active: z.object({
- since: z.number().int().nonnegative(),
- through: z.number().int().nonnegative(),
- }).strict().optional(),
+const timingStateSchema = z.object({
+ settledMs: settledMsField,
+ active: activeField,
+ /** Latest pre-descriptor turn start, promoted when the child's own descriptor arrives. */
pendingTurnStart: z.number().int().nonnegative().optional(),
+ /** Whether the fold has crossed a descriptor in this logical log. */
descriptorSeen: z.boolean(),
}).strict()
+/** Fold state for descriptor-relative active-turn duration. */
+export type TimingState = z.infer
+
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionStateMap {
subagentTiming: TimingState
- subagent: IdentityState
}
}
@@ -61,6 +54,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
*/
export const subagentTimingProjectionDefinition = {
key: 'subagentTiming',
+ stateVersion: 2,
stateSchema: timingStateSchema,
init: () => ({ descriptorSeen: false, settledMs: 0 }),
apply: (state, event) => {
@@ -102,14 +96,8 @@ export const subagentTimingProjectionDefinition = {
...(state.active === undefined ? {} : { active: state.active }),
}),
},
- stateVersion: 2,
} satisfies ProjectionDefinition<'subagentTiming', TimingState>
-interface IdentityState {
- /** Identity from the last valid descriptor; absent before one, and after an invalid one. */
- identity?: SubagentIdentityProjection | undefined
-}
-
// The cast bridges only the optional-label arm: Zod's optional output
// includes explicit `undefined`, which exactOptionalPropertyTypes excludes
// from the public interface. The no-value state itself is the serializable
@@ -130,10 +118,6 @@ const identityValueSchema = z.discriminatedUnion('mode', [
const identitySchema = identityValueSchema.nullable()
-const identityStateSchema: z.ZodType = z.object({
- identity: identityValueSchema.optional(),
-}).strict()
-
/** Interpret one `subagent/descriptor` event's identity; no value when the payload cannot be trusted. */
function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | undefined {
let descriptor: SubagentDescriptorData | undefined
@@ -167,15 +151,13 @@ function descriptorIdentity(event: SessionEvent): SubagentIdentityProjection | u
*/
export const subagentIdentityProjectionDefinition = {
key: 'subagent',
- stateSchema: identityStateSchema,
- init: () => ({}),
+ stateVersion: 3,
+ stateSchema: identitySchema,
+ persist: true,
+ init: () => null,
apply: (state, event) => {
if (event.type !== 'subagent/descriptor') return state
const identity = descriptorIdentity(event)
- return identity === undefined ? {} : { identity }
+ return identity === undefined ? null : identity
},
- wire: { viewSchema: identitySchema, view: state => state.identity ?? null },
- // Bumped when the identity gained its `seq` field: an older checkpoint row
- // would replay into a value the schema rejects, so it must refold instead.
- stateVersion: 2,
-} satisfies ProjectionDefinition<'subagent', IdentityState>
+} satisfies ProjectionDefinition<'subagent', SubagentIdentityProjection | null>
diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
index 3f2c0b1e31..eb69b47119 100644
--- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
+++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts
@@ -15,13 +15,14 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
-import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
-import { SessionId } from '@deepseek-ai/dsh-session'
+import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
-import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval'
+import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import SubagentRuntime from '../src/index.ts'
@@ -39,6 +40,7 @@ async function setup(script: Script) {
const ctx = new Context()
contexts.push(ctx)
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
@@ -73,6 +75,14 @@ function policyEvents(events: readonly SessionEvent[]) {
return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy')
}
+function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown {
+ return ctx.sessionProjections.snapshot(Session.create(id, events)).values.sandboxMode
+}
+
+function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown {
+ return ctx.sessionProjections.snapshot(Session.create(id, events)).values.approvalPolicy
+}
+
describe('continuable policy inheritance', () => {
it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => {
const { ctx, parent } = await setup([textResponse('child done')])
@@ -98,8 +108,8 @@ describe('continuable policy inheritance', () => {
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
// Durable: a reload folds the same effective policy.
- expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access')
- expect(effectiveApprovalPolicy(loaded.events)).toBe('never')
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('danger-full-access')
+ expect(foldedApprovalPolicy(ctx, started.childId, loaded.events)).toBe('never')
expect(ctx.approval.overrideOf(parent.session)).toBeUndefined()
const runtimeContext = loaded.events.find(
(event): event is SessionEvent<'user/message'> => event.type === 'user/message'
@@ -125,7 +135,7 @@ describe('continuable policy inheritance', () => {
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access')
- expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only')
})
it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => {
@@ -138,7 +148,7 @@ describe('continuable policy inheritance', () => {
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
- expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull()
})
it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => {
@@ -157,7 +167,7 @@ describe('continuable policy inheritance', () => {
expect(policyEvents(loaded.events)).toMatchObject([
{ type: 'approval/policy', data: { policy: 'never', source: 'delegation' } },
])
- expect(effectiveSandboxMode(loaded.events)).toBeUndefined()
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBeNull()
})
it('lets a later child-side switch win over the delegation snapshot', { timeout: 20_000 }, async () => {
@@ -177,7 +187,7 @@ describe('continuable policy inheritance', () => {
await waitNoActivation(ctx, started.childId)
const loaded = await ctx.sessionPersistence.load(started.childId)
- expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only')
})
it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => {
@@ -199,7 +209,7 @@ describe('continuable policy inheritance', () => {
expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([
{ data: { mode: 'read-only', source: 'delegation' } },
])
- expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only')
// The approval pin is seeded once at creation, never re-appended on resume.
expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([
{ data: { policy: 'never', source: 'delegation' } },
@@ -226,6 +236,6 @@ describe('continuable policy inheritance', () => {
{ data: { mode: 'workspace-write' } },
{ data: { mode: 'read-only', source: 'delegation' } },
])
- expect(effectiveSandboxMode(loaded.events)).toBe('read-only')
+ expect(foldedSandboxMode(ctx, started.childId, loaded.events)).toBe('read-only')
})
})
diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts
index d7ef69a593..7d85b5a595 100644
--- a/packages/subagent/subagent/tests/continuation.spec.ts
+++ b/packages/subagent/subagent/tests/continuation.spec.ts
@@ -9,6 +9,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork-in-process'
import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm'
@@ -60,6 +61,9 @@ afterEach(() => {
async function setupWith(adapter: LlmAdapter, options: { persistence?: boolean } = {}) {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ // The registry is a required injection of AgentLoop and SubagentRuntime
+ // (both register projection units on activation).
+ await ctx.plugin(SessionProjectionRegistry)
let disposePersistence: (() => Promise) | undefined
let root: string | undefined
if (options.persistence !== false) {
@@ -404,6 +408,7 @@ describe('SubagentRuntime.startContinuable', () => {
const fresh = new Context()
await mountAgentLoopTestDependencies(fresh)
+ await fresh.plugin(SessionProjectionRegistry)
await fresh.plugin(JsonlSessionPersistence, { root: root! })
await fresh.plugin(AgentLoop, { agents: [] })
await fresh.plugin(SubagentRuntime)
@@ -2431,6 +2436,7 @@ describe('continuable errors', () => {
const adapter = new GatedAdapter([{ chunks: textResponse('child'), gate: hold.promise }])
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
const root = mkdtempSync(join(tmpdir(), 'dsh-subagent-continuation-'))
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts
index 91200abf8d..597c22223c 100644
--- a/packages/subagent/subagent/tests/invariant.spec.ts
+++ b/packages/subagent/subagent/tests/invariant.spec.ts
@@ -10,9 +10,13 @@ import type {
} from '@deepseek-ai/dsh-subagent'
import * as SubagentInvariant from '@deepseek-ai/dsh-subagent/invariant'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
async function setup(): Promise {
const ctx = new Context()
+ // The registry is a required injection of SubagentRuntime (its projection
+ // units register in the constructor).
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(InvariantRegistry)
await ctx.plugin(SubagentInvariant)
diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts
index e9a5633189..018b11f16e 100644
--- a/packages/subagent/subagent/tests/list-children.spec.ts
+++ b/packages/subagent/subagent/tests/list-children.spec.ts
@@ -2,16 +2,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
-import { z } from 'zod'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
+import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
-import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import SessionProjectionCache from '@deepseek-ai/dsh-session-projection-cache'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
@@ -55,8 +54,10 @@ async function setup(
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(SubagentFork, { providerName: 'fork' })
ctx.llm.registerAdapter(['mock'], new MockAdapter(script))
- const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
- return { ctx, parent }
+ const parent = ctx.get('agentLoop') === undefined
+ ? undefined
+ : ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' })
+ return { ctx, parent: parent as Agent }
}
const testSignal = new AbortController().signal
@@ -117,40 +118,6 @@ function descriptorPayload(label: string, version = SUBAGENT_DESCRIPTOR_VERSION)
return { version, mode: 'continuable' as const, provider: 'spawn', label }
}
-declare module '@deepseek-ai/dsh-session-projection/types' {
- interface SessionProjectionStateMap {
- subagentListHostileProbe: { poisoned?: boolean | undefined }
- }
- interface SessionProjectionMap {
- /** Test-only hostile probe proving per-child isolation of foreign unit failures. */
- subagentListHostileProbe: null
- }
-}
-
-/**
- * A foreign registered unit that rejects one specific child's log at view
- * time: `apply` never throws (the eager drive passes every committed event
- * through it), while the poisoned state detonates only when a listing read
- * folds or serves this child through the registry.
- */
-const hostileProjectionDefinition = {
- key: 'subagentListHostileProbe',
- stateSchema: z.object({ poisoned: z.boolean().optional() }),
- init: () => ({}),
- apply: (state, event) =>
- event.type === 'subagent/descriptor' && (event.data as { label?: string }).label === 'poison me'
- ? { poisoned: true }
- : state,
- wire: {
- viewSchema: z.null(),
- view: (state) => {
- if (state.poisoned === true) throw new Error('hostile unit rejects the poisoned log')
- return null
- },
- },
- stateVersion: 1,
-} satisfies ProjectionDefinition<'subagentListHostileProbe', { poisoned?: boolean | undefined }>
-
describe('SubagentRuntime.listChildren', () => {
it('lists live children without persistence, query services, or the continuation runtime', async () => {
const ctx = new Context()
@@ -180,11 +147,10 @@ describe('SubagentRuntime.listChildren', () => {
])
})
- it('fails loud when the projection registry is not mounted, even with no children', async () => {
- const { ctx, parent } = await setup([], { sessionProjections: false })
- await expect(ctx.subagents.listChildren(parent.id)).rejects.toThrow(
- expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
- )
+ it('never activates without the projection registry (mandatory seam)', async () => {
+ const { ctx } = await setup([], { sessionProjections: false })
+ expect(ctx.get('subagents')).toBeUndefined()
+ expect(ctx.get('agentLoop')).toBeUndefined()
})
it('fails loud when the session store is not mounted', async () => {
@@ -595,47 +561,6 @@ describe('SubagentRuntime.listChildren', () => {
])
})
- it('contains a foreign unit failure during a cold fold to that child as corrupt', async () => {
- const { ctx, parent } = await setup([textResponse('done')])
- ctx.sessionProjections.register(hostileProjectionDefinition)
- const healthy = await startChild(ctx, parent, 'healthy sibling')
- const poisoned = await authorChild(ctx, '00000000-0000-4000-8000-00000000d00d', {
- parentSession: parent.id,
- origin: 'subagent',
- }, childEvents(descriptorPayload('poison me')))
- // The subagent unit itself folds this child cleanly; the FOREIGN unit's
- // view throws, and that damage stays contained to the one child.
- const entries = await ctx.subagents.listChildren(parent.id)
- expect(entries).toContainEqual({ kind: 'diagnostic', id: poisoned, reason: 'corrupt' })
- expect(entries).toContainEqual({
- kind: 'child', id: healthy, label: 'healthy sibling', mode: 'continuable',
- activity: 'inactive', hasChildren: false,
- })
- })
-
- it('contains a foreign unit failure during a live snapshot to that child as corrupt', async () => {
- const { ctx, parent } = await setup([])
- ctx.sessionProjections.register(hostileProjectionDefinition)
- const poisonedId = SessionId('live-poisoned-child')
- const poisoned = ctx.sessions.create(poisonedId, {
- meta: { parentSession: parent.id, origin: 'subagent' },
- })
- poisoned.append('turn/start', { turn: 1 })
- poisoned.append('subagent/descriptor', descriptorPayload('poison me'))
- const healthyId = SessionId('live-healthy-child')
- const healthy = ctx.sessions.create(healthyId, {
- meta: { parentSession: parent.id, origin: 'subagent' },
- })
- healthy.append('turn/start', { turn: 1 })
- healthy.append('subagent/descriptor', descriptorPayload('live healthy'))
- const entries = await ctx.subagents.listChildren(parent.id)
- expect(entries).toContainEqual({ kind: 'diagnostic', id: poisonedId, reason: 'corrupt' })
- expect(entries).toContainEqual({
- kind: 'child', id: healthyId, label: 'live healthy', mode: 'continuable',
- activity: 'running', hasChildren: false,
- })
- })
-
it('fails the whole enumeration when the persisted listing itself fails', async () => {
const { ctx, parent } = await setup([textResponse('done')])
await startChild(ctx, parent, 'never listed')
@@ -775,8 +700,9 @@ describe('SubagentRuntime.listChildren', () => {
// The child's turn/end and disposal are the cache's mandatory checkpoint
// points; both writes are fail-soft asynchronous, so wait for the row.
const header = (await ctx.sessionPersistence.list()).find(meta => meta.id === childId)
- await vi.waitFor(() => {
- expect(ctx.sessionProjectionCache.cachedSnapshot(header!)?.values.subagent).toBeDefined()
+ await vi.waitFor(async () => {
+ const cut = ctx.sessionProjectionCache.cachedSnapshot(header!)
+ expect(cut?.values.subagent).toBeDefined()
}, { timeout: 5_000 })
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
@@ -972,10 +898,14 @@ describe('SubagentRuntime.listChildren', () => {
})
it('SubagentError from listChildren is typed with its stable code', async () => {
- const { ctx, parent } = await setup([], { sessionProjections: false })
- const caught: unknown = await ctx.subagents.listChildren(parent.id).catch((error: unknown) => error)
+ const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
+ await ctx.plugin(SubagentRuntime)
+ const caught: unknown = await ctx.subagents
+ .listChildren(SessionId('no-store-parent'))
+ .catch((error: unknown) => error)
expect(caught).toBeInstanceOf(SubagentError)
- expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE')
+ expect((caught as SubagentError).code).toBe('SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE')
})
})
@@ -1211,10 +1141,8 @@ describe('SubagentRuntime.listDescendants', () => {
expect(list).not.toHaveBeenCalled()
})
- it('fails loud when the projection registry is not mounted', async () => {
- const { ctx, parent } = await setup([], { sessionProjections: false })
- await expect(ctx.subagents.listDescendants(parent.id)).rejects.toThrow(
- expect.objectContaining({ code: 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE' }) as Error,
- )
+ it('does not activate when the projection registry is not mounted', async () => {
+ const { ctx } = await setup([], { sessionProjections: false })
+ expect(ctx.subagents).toBeUndefined()
})
})
diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts
index 05e9785611..c998ca2c02 100644
--- a/packages/subagent/subagent/tests/service.spec.ts
+++ b/packages/subagent/subagent/tests/service.spec.ts
@@ -19,6 +19,7 @@ import SubagentRuntime, {
type SubagentStartRequest,
} from '@deepseek-ai/dsh-subagent'
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
function fakeParent(id = 'parent-1'): Agent {
return { id: SessionId(id) } as unknown as Agent
@@ -64,6 +65,9 @@ class StubProvider implements SubagentProvider {
async function service(): Promise<{ ctx: Context; subagents: SubagentRuntime }> {
const ctx = new Context()
+ // The registry is a required injection of SubagentRuntime (its projection
+ // units register in the constructor).
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
return { ctx, subagents: ctx.subagents }
}
diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
index 4eb8008c3b..4c3ba93d13 100644
--- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
+++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts
@@ -247,6 +247,7 @@ describe('dsh-tool-subagent-control/list-agents', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'list_agents')).toBe(true)
diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
index 0636254841..9abe4a5a88 100644
--- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
+++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts
@@ -207,6 +207,7 @@ describe('dsh-tool-subagent-control', () => {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(tool)
expect(ctx.tools.schemas().some(schema => schema.name === 'send_message')).toBe(true)
diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json
index dbadc60e88..9eb9252af1 100644
--- a/packages/subagent/tool-subagent-report/package.json
+++ b/packages/subagent/tool-subagent-report/package.json
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts
index 28d6068757..90fec316e0 100644
--- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts
+++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts
@@ -13,6 +13,7 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import * as control from '@deepseek-ai/dsh-tool-subagent-control'
import { textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -68,6 +69,7 @@ async function setup(options: { load?: boolean; config?: tool.Config } = {}) {
const root = mkdtempSync(join(tmpdir(), 'dsh-tool-subagent-report-'))
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
const fiber = options.load === false
diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json
index 8de6a04739..2549e46501 100644
--- a/packages/subagent/tool-subagent/package.json
+++ b/packages/subagent/tool-subagent/package.json
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts
index 67ebfb1388..d54fa53c3b 100644
--- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts
+++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { type Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as scripted from './scripted-provider.ts'
@@ -21,6 +22,7 @@ function baseRequest(over: Partial = {}): SubagentStartReq
async function mount(config: Partial = {}): Promise {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await scripted.mountScriptedProvider(ctx, { name: 'mock', ...config })
return ctx
@@ -89,6 +91,7 @@ describe('scripted subagent provider fixture', () => {
it('unregisters with its owning fixture fiber', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' })
expect(ctx.subagents.list()).toEqual(['mock'])
diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
index 1ee5e40228..f731622d2b 100644
--- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
+++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts
@@ -13,6 +13,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import type { SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
@@ -41,6 +42,7 @@ async function setup(toolConfig: tool.Config, mockConfig: Partial =
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig })
await ctx.plugin(tool, toolConfig)
@@ -208,6 +210,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await mock.mountScriptedProvider(ctx, { name: 'spawn', reply: 'from spawn' })
await mock.mountScriptedProvider(ctx, { name: 'acp', reply: 'from acp' })
@@ -229,6 +232,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'weird',
@@ -255,6 +259,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'capture',
@@ -285,6 +290,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'bare',
@@ -320,6 +326,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
// Tool first: no provider yet — the tool must be absent, not broken.
// Direct apply (schema bypass): also covers the waiting-note's default
@@ -337,6 +344,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
tool.apply(ctx, {
provider: 'later-continuable',
@@ -353,6 +361,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const backend = await mock.mountScriptedProvider(ctx, { name: 'mock' }) // fresh conversation (descriptor: false)
await ctx.plugin(tool, { provider: 'mock' })
@@ -372,6 +381,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
// Arm 1: a mounted tool and its prompt section die with the plugin fiber;
@@ -408,6 +418,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await mock.mountScriptedProvider(ctx, { name: 'mock' })
await ctx.plugin(tool, { provider: 'mock' })
@@ -444,6 +455,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -467,6 +479,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -491,6 +504,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -519,6 +533,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -546,6 +561,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -585,6 +601,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spy',
@@ -649,6 +666,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'capture2',
@@ -706,6 +724,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'capture3',
@@ -736,6 +755,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'capture4',
@@ -761,6 +781,7 @@ describe('dsh-tool-subagent', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'p',
@@ -1088,6 +1109,7 @@ describe('dsh-tool-subagent continuable background mode', () => {
roots.push(root)
await ctx.plugin(JsonlSessionPersistence, { root })
await ctx.plugin(AgentLoop, { agents: [] })
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
await ctx.plugin(LocalJobRegistry)
@@ -1292,6 +1314,7 @@ describe('depth budget configuration', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'capture',
@@ -1330,6 +1353,7 @@ describe('depth budget configuration', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'no-depth',
@@ -1346,6 +1370,7 @@ describe('depth budget configuration', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'external',
diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json
index b1b8ae6a59..b6042e122b 100644
--- a/packages/terminal/terminal-bash/package.json
+++ b/packages/terminal/terminal-bash/package.json
@@ -39,7 +39,8 @@
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts
index d0b2d6a564..a9a17950b4 100644
--- a/packages/terminal/terminal-bash/src/index.ts
+++ b/packages/terminal/terminal-bash/src/index.ts
@@ -11,7 +11,8 @@ import { TerminalBackendCleanupError } from '@deepseek-ai/dsh-terminal'
import type { TerminalBackend, TerminalBackendSpawnSpec } from '@deepseek-ai/dsh-terminal'
import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
-import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-sandbox-policy'
+import type {} from '@deepseek-ai/dsh-session-projection'
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
import { LocalPtySession } from './session.ts'
import { CONTROLLED_PROMPT } from './sanitize.ts'
@@ -21,12 +22,13 @@ export type { Config as TerminalLocalConfig } from './config.ts'
/** Cordis plugin name. */
export const name = 'terminal-bash'
-/** Required services: PTY registry, shared confinement policy, and process substrate. */
-export const inject = ['terminals', 'sandboxPolicy', 'subprocess']
+/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */
+export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess']
interface SandboxModeFenceState {
pty: Context['terminals']
sandboxPolicy: Context['sandboxPolicy']
+ sessionProjections: Context['sessionProjections']
}
const sandboxModeFences = new WeakMap()
@@ -36,15 +38,21 @@ 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 }
+ 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 currentMode = effectiveSandboxMode(session.events) ?? state.sandboxPolicy.defaultMode
+ const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null
+ const currentMode = folded ?? state.sandboxPolicy.defaultMode
if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return
throw new Error(
`cannot change sandbox mode from "${currentMode}" to "${event.data.mode}" while persistent terminal sessions are open or being created; wait for creation to settle and close them first`,
diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts
index 2a4d7848df..015b6562ed 100644
--- a/packages/terminal/terminal-bash/tests/index.spec.ts
+++ b/packages/terminal/terminal-bash/tests/index.spec.ts
@@ -7,6 +7,7 @@ import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import TerminalSessionService, { TerminalBackendCleanupError, TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
@@ -99,7 +100,7 @@ function stubLocalSession(initialize: () => Promise = () => Promise.resolv
}
function registerStubLocalBackend(ctx: Context, createSession: () => LocalPtySession) {
- return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'subprocess'], (providerCtx) => {
+ return ctx.inject(['terminals', 'sandbox', 'sandboxPolicy', 'sessionProjections', 'subprocess'], (providerCtx) => {
providerCtx.terminals.registerBackend(new BashTerminalBackend(
providerCtx,
{ ...config(), backendType: 'stub' },
@@ -113,6 +114,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('rejects pre-aborted setup and empty sandbox argv', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/tmp' })
const backend = new BashTerminalBackend(ctx, config(), async () => terminalHandle())
const controller = new AbortController()
@@ -124,6 +126,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('closes failed startup and aggregates cleanup failure', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const spawnTerminal = async (): Promise => terminalHandle()
@@ -149,6 +152,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('starts startup rollback when cancellation wins a stalled initialization', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const initialization = Promise.withResolvers()
const initializationStarted = Promise.withResolvers()
@@ -176,6 +180,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('wraps confined argv, scrubs the environment, and returns initialized sessions', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const terminal = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
@@ -223,6 +228,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('resolves session mode and root together before wrapping the shell', async () => {
const ctx = new Context()
await ctx.plugin(RecordingSandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'read-only', workspaceRoot: '/deployment-fallback' })
const terminal = terminalHandle()
let spawned: SubprocessTerminalSpawnSpec | undefined
@@ -254,6 +260,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('rejects a confined spawn without a sandbox provider', async () => {
const confinedCtx = new Context()
+ await confinedCtx.plugin(SessionProjectionRegistry)
await confinedCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/workspace' })
const confined = new BashTerminalBackend(
confinedCtx,
@@ -269,6 +276,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('forwards terminal allocation cancellation directly', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
const publishedController = new AbortController()
@@ -313,6 +321,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('composes the default local session around a spawned terminal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
const output = new PassThrough()
const outcome = Promise.withResolvers<{ exitCode: number | null; signal: NodeJS.Signals | null }>()
@@ -346,7 +355,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', 'subprocess'])
+ expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess'])
expect(unwrapped.Config).toBeDefined()
})
@@ -354,6 +363,7 @@ describe('terminal-bash plugin shape', () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
const fiber = await ctx.plugin(ptyLocal, config())
@@ -368,6 +378,7 @@ describe('terminal-bash plugin shape', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(EmptySandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
await ctx.plugin(ptyLocal, config())
@@ -385,6 +396,7 @@ describe('terminal-bash plugin shape', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(RecordingSandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
@@ -434,6 +446,7 @@ describe('terminal-bash plugin shape', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(RecordingSandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/tmp' })
await ctx.plugin(StubSubprocessRuntime)
diff --git a/packages/terminal/terminal-bash/tests/local.spec.ts b/packages/terminal/terminal-bash/tests/local.spec.ts
index c7af8668a3..e4f5712656 100644
--- a/packages/terminal/terminal-bash/tests/local.spec.ts
+++ b/packages/terminal/terminal-bash/tests/local.spec.ts
@@ -11,6 +11,7 @@ import type { TerminalSendOperation } from '@deepseek-ai/dsh-terminal'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as ptyLocal from '@deepseek-ai/dsh-terminal-bash'
@@ -57,6 +58,7 @@ async function harness(
await ctx.plugin(AgentRegistry)
await ctx.plugin(TerminalSessionService)
await ctx.plugin(PassthroughSandbox)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode, workspaceRoot: root })
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(ptyLocal, {
diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json
index 9fe3475e74..d43bf9737a 100644
--- a/packages/terminal/tool-terminal/package.json
+++ b/packages/terminal/tool-terminal/package.json
@@ -63,6 +63,7 @@
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
index ada46ef78f..279198a8a1 100644
--- a/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
+++ b/packages/terminal/tool-terminal/tests/loader-composition.spec.ts
@@ -16,6 +16,7 @@ import TerminalSessionService from '@deepseek-ai/dsh-terminal'
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as TerminalLocal from '@deepseek-ai/dsh-terminal-bash'
import * as ToolPty from '@deepseek-ai/dsh-tool-terminal'
@@ -69,6 +70,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
"- name: '@deepseek-ai/dsh-tools'",
"- name: '@deepseek-ai/dsh-terminal'",
"- name: '@deepseek-ai/dsh-test-sandbox'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-sandbox-policy'",
' config:',
' mode: danger-full-access',
@@ -96,6 +98,7 @@ suite('terminal real Loader composition through cordis.yml', () => {
['@deepseek-ai/dsh-tools', ToolRuntime],
['@deepseek-ai/dsh-terminal', TerminalSessionService],
['@deepseek-ai/dsh-test-sandbox', PassthroughSandbox],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-sandbox-policy', SandboxPolicyService],
['@deepseek-ai/dsh-subprocess-local', LocalSubprocessRuntime],
['@deepseek-ai/dsh-terminal-bash', TerminalLocal],
diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml
index 469cb71023..e386d0cfcb 100644
--- a/packages/todo/tool-todo/README.i18n.yaml
+++ b/packages/todo/tool-todo/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md
-README.md: 4848758dd8f2049221901744e5d09cef2dd31591
-README.zh.md: f3e9e50e1c548dc8c0d01b0bae5c91dd813b5899
+README.md: dfd9b59c19a65f128068514ddef2a4b078ff99cb
+README.zh.md: 055ac3f20a599a75d302435459e5c8040dade30b
diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md
index 4848758dd8..dfd9b59c19 100644
--- a/packages/todo/tool-todo/README.md
+++ b/packages/todo/tool-todo/README.md
@@ -30,7 +30,7 @@ The canonical result is `{ todos, counts: { pending, inProgress, completed } }`;
## Session projection
-When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `todos` projection unit under an injected child: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), `view` = identity, `stateVersion` = 2. The key merges into `SessionProjectionMap` here (via the Service Definition package's `/types` outlet); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md).
+This package requires `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)) and registers the `todos` projection unit: `init` = `null` (no write yet), `apply` = take the whole list from each `todo/write` and clear to `null` on each `turn/start` (standing plan; `turn/end` keeps the finished checklist; every other event returns the same state reference), client view = identity, `stateVersion` = 2. The key merges into both `SessionProjectionStateMap` and `SessionProjectionMap`; carriers serve the client value on the history tail page and the `session/projection` push frame. Lifetime rationale: [todo plan clears on next turn](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md).
## Export shape
diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md
index f3e9e50e1c..055ac3f20a 100644
--- a/packages/todo/tool-todo/README.zh.md
+++ b/packages/todo/tool-todo/README.zh.md
@@ -30,7 +30,7 @@
## 会话投影
-当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经 Service Definition 包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。
+本包要求组合提供 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)),并注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、客户端视图 = 恒等、`stateVersion` = 2。该 key 同时合并进 `SessionProjectionStateMap` 与 `SessionProjectionMap`;载体通过历史尾页与 `session/projection` 推送帧提供客户端值。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。
## 导出形状
diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts
index 9c0a8e45a7..ef82b2f19d 100644
--- a/packages/todo/tool-todo/src/index.ts
+++ b/packages/todo/tool-todo/src/index.ts
@@ -11,16 +11,15 @@ import { z as zod } from 'zod'
import type { ZodType } from 'zod'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { TodoItem } from '@deepseek-ai/dsh-session'
-// Type-only: resolves ctx.sessionProjections for the optional unit child.
import type {} from '@deepseek-ai/dsh-session-projection'
// The `todos` projection-key declaration lives in src/types.ts (its one home);
// this re-export projects the type face onto the package root AND keeps the
// module edge in the emitted index.d.ts, so aggregate programs consuming the
-// declarations still receive the SessionProjectionMap merge.
+// declarations still receive the SessionProjectionStateMap merge.
export type * from './types.ts'
export const name = 'tool-todo'
-export const inject = ['tools']
+export const inject = ['tools', 'sessionProjections']
/** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */
const STATUSES = ['pending', 'in_progress', 'completed'] as const
@@ -120,31 +119,24 @@ const todosProjectionSchema: ZodType = zod.union([
])
/**
- * Register the `todo_write` tool on `ctx.tools` and, when the session-projection seam is composed,
- * the `todos` unit.
- * @param ctx - registrant context carrying the tool registry.
+ * Register the `todo_write` tool and `todos` projection.
+ * @param ctx - registrant context carrying the tool and projection registries.
* @param config - deployment's explicit todo policy.
*/
export function apply(ctx: Context, config: Config): void {
const allowParallel = config.allowParallelInProgress
- // The unit child activates only when a projection registry is composed
- // (headless assemblies without the seam stay unaffected). 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.inject(['sessionProjections'], (projectionCtx) => {
- projectionCtx.sessionProjections.register<'todos', TodoItem[] | null>({
- key: 'todos',
- stateSchema: todosProjectionSchema,
- init: () => null,
- apply: (state, event) => {
- if (event.type === 'todo/write') return event.data.todos
- if (event.type === 'turn/start') return null
- return state
- },
- wire: { viewSchema: todosProjectionSchema, view: state => state },
- stateVersion: 2,
- })
+ ctx.sessionProjections.register({
+ key: 'todos',
+ stateVersion: 2,
+ stateSchema: todosProjectionSchema,
+ init: () => null,
+ apply: (state, event) => {
+ if (event.type === 'todo/write') return event.data.todos
+ if (event.type === 'turn/start') return null
+ return state
+ },
+ wire: { viewSchema: todosProjectionSchema, view: state => state },
+
})
ctx.tools.register(defineTool({
name: 'todo_write',
diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts
index 264731bdbc..0692772ea9 100644
--- a/packages/todo/tool-todo/tests/integration.spec.ts
+++ b/packages/todo/tool-todo/tests/integration.spec.ts
@@ -5,6 +5,7 @@ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
@@ -17,6 +18,7 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent
async function harness(adapter: MockAdapter): Promise {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(ToolTodo, { allowParallelInProgress: true })
ctx.llm.registerAdapter(['mock'], adapter)
diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts
index 945b2e84e5..e68ee90b3a 100644
--- a/packages/todo/tool-todo/tests/loader-composition.spec.ts
+++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts
@@ -15,6 +15,7 @@ import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as ToolTodo from '@deepseek-ai/dsh-tool-todo'
let root: string | undefined
@@ -58,6 +59,7 @@ async function boot(configLines: readonly string[]): Promise {
"- name: '@deepseek-ai/dsh-agent'",
"- name: '@deepseek-ai/dsh-system-prompt'",
"- name: '@deepseek-ai/dsh-tools'",
+ "- name: '@deepseek-ai/dsh-session-projection'",
"- name: '@deepseek-ai/dsh-tool-todo'",
...configLines.length > 0 ? [' config:', ...configLines] : [],
'',
@@ -72,6 +74,7 @@ async function boot(configLines: readonly string[]): Promise {
['@deepseek-ai/dsh-agent', AgentRegistry],
['@deepseek-ai/dsh-system-prompt', SystemPrompt],
['@deepseek-ai/dsh-tools', ToolRuntime],
+ ['@deepseek-ai/dsh-session-projection', SessionProjectionRegistry],
['@deepseek-ai/dsh-tool-todo', ToolTodo],
])
ctx.loader.internal = {
diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts
index dd959f2f9f..f6babf0157 100644
--- a/packages/todo/tool-todo/tests/tool-todo.spec.ts
+++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts
@@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRuntime from '@deepseek-ai/dsh-tools'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { TodoItem } from '@deepseek-ai/dsh-session'
import { type Agent } from '@deepseek-ai/dsh-agent'
@@ -30,6 +31,7 @@ async function setup(allowParallelInProgress: boolean): Promise {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(tool, { allowParallelInProgress })
return ctx
}
@@ -213,6 +215,7 @@ describe('dsh-tool-todo', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
const fiber = await ctx.plugin(tool, { allowParallelInProgress: true })
expect(ctx.tools.schemas().some(s => s.name === 'todo_write')).toBe(true)
await fiber.dispose()
@@ -223,13 +226,13 @@ describe('dsh-tool-todo', () => {
// A default export would make Loader unwrap only apply and drop `inject`.
expect('default' in tool).toBe(false)
expect(tool.name).toBe('tool-todo')
- expect(tool.inject).toEqual(['tools'])
+ expect(tool.inject).toEqual(['tools', 'sessionProjections'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(tool) as Record
expect(unwrapped).toBe(tool)
expect(unwrapped.name).toBe('tool-todo')
- expect(unwrapped.inject).toEqual(['tools'])
+ expect(unwrapped.inject).toEqual(['tools', 'sessionProjections'])
expect(typeof unwrapped.apply).toBe('function')
})
})
diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json
index a795d22ca8..b87c901ef6 100644
--- a/packages/workflow/tool-ralph/package.json
+++ b/packages/workflow/tool-ralph/package.json
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts
index e6de6c38ef..771a57afb0 100644
--- a/packages/workflow/tool-ralph/tests/integration.spec.ts
+++ b/packages/workflow/tool-ralph/tests/integration.spec.ts
@@ -5,6 +5,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
@@ -20,6 +21,7 @@ async function mountRalph(script: MockScript, config: toolRalph.Config) {
const ctx = new Context()
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
await ctx.plugin(spawn, { providerName: 'spawn' })
@@ -57,6 +59,7 @@ describe('dsh-tool-ralph over the real spawn and worker-thread stack', () => {
toolCallResponse('round-2', STRUCTURED_OUTPUT_TOOL, finalReport),
])
await mountAgentLoopTestDependencies(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
await ctx.plugin(spawn, { providerName: 'spawn' })
diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
index 7c6f02e36c..69a699a245 100644
--- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
+++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts
@@ -4,6 +4,7 @@ import Loader from '@deepseek-ai/cordis-plugin-loader'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { CallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentCapabilities, SubagentProvider, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
@@ -78,6 +79,7 @@ async function setup(options?: SetupOptions) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const provider = options?.provider === false ? undefined : options?.provider ?? new StubProvider()
if (provider !== undefined) ctx.subagents.registerProvider(provider)
diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json
index 2bd0cd13a9..595459bc2e 100644
--- a/packages/workflow/tool-workflow/package.json
+++ b/packages/workflow/tool-workflow/package.json
@@ -59,6 +59,7 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/dsh-workflow-worker-thread": "workspace:^",
- "@deepseek-ai/cordis": "workspace:^"
+ "@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
index 938f9a5502..26847b7098 100644
--- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
+++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts
@@ -15,6 +15,7 @@ import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
import * as toolWorkflow from '../src/index.ts'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
const testToolSignal = new AbortController().signal
@@ -420,6 +421,7 @@ describe('dsh-tool-workflow', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
ctx.subagents.registerProvider({
name: 'spawn',
diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json
index 8e2c094009..24fd17ae98 100644
--- a/packages/workflow/workflow-worker-thread/package.json
+++ b/packages/workflow/workflow-worker-thread/package.json
@@ -64,6 +64,7 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workflow": "workspace:^",
"@deepseek-ai/cordis": "workspace:^",
- "tsx": "^4.19.2"
+ "tsx": "^4.19.2",
+ "@deepseek-ai/dsh-session-projection": "workspace:^"
}
}
diff --git a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts
index 31998a22cf..f8714a5ac5 100644
--- a/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts
+++ b/packages/workflow/workflow-worker-thread/tests/built-worker.e2e.ts
@@ -22,10 +22,12 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work
try {
await writeFile(driver, `
import { Context } from '@deepseek-ai/cordis'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import WorkerThreadWorkflowEngine from '@deepseek-ai/dsh-workflow-worker-thread'
const ctx = new Context()
+await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
let selectedStarts = 0
ctx.subagents.registerProvider({
diff --git a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts
index 33f5575693..686c342ad5 100644
--- a/packages/workflow/workflow-worker-thread/tests/integration.spec.ts
+++ b/packages/workflow/workflow-worker-thread/tests/integration.spec.ts
@@ -7,6 +7,7 @@ import InvariantRegistry from '@deepseek-ai/dsh-invariants'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-in-process-driver'
@@ -35,6 +36,7 @@ async function setup(script: Script) {
const adapter = new MockAdapter(script)
await mountAgentLoopTestDependencies(ctx)
await mountInvariants(ctx)
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(SubagentRuntime)
await ctx.plugin(spawn, { providerName: 'spawn' })
diff --git a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts
index 8d33e373c7..19c7e79f69 100644
--- a/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts
+++ b/packages/workflow/workflow-worker-thread/tests/source-worker.compat.spec.ts
@@ -9,6 +9,7 @@ import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import WorkerThreadWorkflowEngine from '../src/index.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
@@ -18,6 +19,7 @@ vi.setConfig({ testTimeout: 30_000 })
it('runs the default config through the source worker', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
const subagents = await ctx.plugin(SubagentRuntime)
const provider: SubagentProvider = {
name: 'spawn',
diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts
index b87d0eb62a..daf7516ca2 100644
--- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts
+++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.e2e.ts
@@ -7,6 +7,7 @@ import ToolRuntime from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
import * as Spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
@@ -32,6 +33,7 @@ async function harness(): Promise {
const built = new Context()
await built.plugin(LlmRuntime)
await built.plugin(SessionStore)
+ await built.plugin(SessionProjectionRegistry)
await built.plugin(SystemPrompt)
await built.plugin(ToolRuntime)
await built.plugin(AgentRegistry)
diff --git a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
index 5130071362..9e90c400b4 100644
--- a/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
+++ b/packages/workflow/workflow-worker-thread/tests/workflow-worker-thread.spec.ts
@@ -13,6 +13,7 @@ import WorkerThreadWorkflowEngine, { type Config } from '../src/index.ts'
import { workerSpawnEnv } from '../src/host.ts'
import { HostToWorkerType, WorkerToHostType } from '../src/protocol.ts'
import { SessionId } from '@deepseek-ai/dsh-session'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
/** A minimal parent stand-in: the engine only threads it through to the provider. */
function fakeParent(): Agent {
@@ -143,6 +144,7 @@ interface SetupOptions {
async function setup(options?: SetupOptions) {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const provider = new StubProvider(
'stub',
@@ -456,6 +458,7 @@ describe('dsh-workflow-worker-thread', () => {
it('a child result REJECTION crosses back as a fatal AGENT_RESULT error (a broken provider is not a failed child)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const provider: SubagentProvider = {
name: 'rejecting',
@@ -515,6 +518,7 @@ describe('dsh-workflow-worker-thread', () => {
it('a child whose dispose() throws synchronously cannot wedge the script (the host acks anyway)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const provider: SubagentProvider = {
name: 'bad-dispose',
@@ -537,6 +541,7 @@ describe('dsh-workflow-worker-thread', () => {
it('a child dispose() rejecting an UNRENDERABLE value still acks — the containment warn is total', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const provider: SubagentProvider = {
name: 'coercion-trap-dispose',
@@ -887,6 +892,7 @@ describe('dsh-workflow-worker-thread', () => {
it('the settle-reap fires the request signal too: a provider honoring ONLY the signal winds its stray down promptly', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const aborted: string[] = []
const provider: SubagentProvider = {
@@ -1182,6 +1188,7 @@ describe('dsh-workflow-worker-thread', () => {
it('refuses and disposes a provider run that becomes ready after its real worker dies', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const requested = Promise.withResolvers()
const ready = Promise.withResolvers()
@@ -1244,6 +1251,7 @@ describe('dsh-workflow-worker-thread', () => {
it('a worker that exits before settling reports an error result and reaps its children', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
// The child's dispose() REJECTS on top of the worker death: the reap
// must contain it (warn, not crash) while still emptying the registry.
@@ -1428,6 +1436,7 @@ describe('dsh-workflow-worker-thread', () => {
it('unregisters ctx.workflowEngine when the engine fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SubagentRuntime)
const fiber = await ctx.plugin(WorkerThreadWorkflowEngine, {})
expect(ctx.get('workflowEngine')).toBeDefined()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 36b32a5873..47cf941286 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -804,6 +804,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -3252,6 +3255,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-token-meter':
specifier: workspace:^
version: link:../../llm/token-meter
@@ -3286,6 +3292,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-token-meter':
specifier: workspace:^
version: link:../../llm/token-meter
@@ -3295,6 +3304,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:^
@@ -3329,6 +3341,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -3366,15 +3381,24 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query
+ '@deepseek-ai/dsh-session-title':
+ specifier: workspace:^
+ version: link:../../session/session-title
packages/context/time-context:
dependencies:
'@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:^
@@ -3400,6 +3424,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -3412,6 +3439,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:^
@@ -3428,6 +3458,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../../shell/shell
@@ -3452,6 +3485,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../system-prompt
@@ -3489,6 +3525,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:^
@@ -3514,6 +3553,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
@@ -3634,6 +3676,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../system-prompt
@@ -3782,6 +3827,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../../session-query/session-query
@@ -3867,6 +3915,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../../session/session-title
@@ -3962,6 +4013,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-sqlite':
specifier: workspace:^
version: link:../../session/session-persistence-sqlite
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -4002,6 +4056,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -4332,6 +4389,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
packages/fs/tool-fs:
dependencies:
@@ -4384,6 +4444,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -4479,6 +4542,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -4512,6 +4578,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
packages/goal/goal:
dependencies:
@@ -4579,6 +4648,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -4601,6 +4673,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-goal':
specifier: workspace:^
version: link:../goal
@@ -4613,6 +4688,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -4647,6 +4725,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-tools':
specifier: workspace:^
version: link:../../core/tools
@@ -4723,6 +4804,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../../shell/shell
@@ -4775,6 +4859,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../../shell/shell
@@ -4875,6 +4962,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
@@ -5149,6 +5239,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:^
@@ -5156,6 +5249,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
@@ -5171,6 +5267,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -5380,6 +5479,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:^
@@ -5423,6 +5525,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-sqlite':
specifier: workspace:^
version: link:../../session/session-persistence-sqlite
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -5693,6 +5798,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-settings':
specifier: workspace:^
version: link:../../settings/settings
@@ -5783,6 +5891,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:^
@@ -5799,6 +5910,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -5939,6 +6053,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -6036,6 +6153,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-sqlite':
specifier: workspace:^
version: link:../../session/session-persistence-sqlite
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../session-query
@@ -6052,6 +6172,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -6067,6 +6190,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-session-query':
specifier: workspace:^
version: link:../session-query
@@ -6121,6 +6247,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../session-projection
'@deepseek-ai/dsh-system-prompt':
specifier: workspace:^
version: link:../../core/system-prompt
@@ -6341,6 +6470,12 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-brand':
specifier: workspace:^
version: link:../../util/brand
@@ -6372,6 +6507,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -6381,6 +6519,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../session-projection
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../session-title
@@ -6403,6 +6544,9 @@ importers:
'@deepseek-ai/cordis-plugin-loader':
specifier: workspace:^
version: link:../../../vendor/loader
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -6415,6 +6559,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../session-projection
'@deepseek-ai/dsh-session-title':
specifier: workspace:^
version: link:../session-title
@@ -6539,6 +6686,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../shell
@@ -6597,6 +6747,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../shell
@@ -6698,6 +6851,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../shell
@@ -6753,6 +6909,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
@@ -6784,6 +6943,9 @@ importers:
'@deepseek-ai/dsh-agent':
specifier: workspace:^
version: link:../../core/agent
+ '@deepseek-ai/dsh-agent-loop':
+ specifier: workspace:^
+ version: link:../../core/agent-loop
'@deepseek-ai/dsh-invariants':
specifier: workspace:^
version: link:../../runtime-diagnostics/invariants
@@ -6808,6 +6970,9 @@ importers:
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-shell':
specifier: workspace:^
version: link:../shell
@@ -6894,6 +7059,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:^
@@ -6913,6 +7081,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-skill':
specifier: workspace:^
version: link:../skill
@@ -7151,6 +7322,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7200,6 +7374,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7246,6 +7423,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7292,6 +7472,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7329,6 +7512,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7377,6 +7563,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7429,6 +7618,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7481,6 +7673,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7572,6 +7767,9 @@ importers:
'@deepseek-ai/dsh-session-persistence-jsonl':
specifier: workspace:^
version: link:../../session/session-persistence-jsonl
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../subagent
@@ -7639,6 +7837,9 @@ importers:
packages/terminal/terminal-bash:
dependencies:
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/schemastery':
specifier: link:../../../vendor/schemastery
version: link:../../../vendor/schemastery
@@ -7713,6 +7914,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
@@ -8255,6 +8459,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -8298,6 +8505,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
@@ -8365,6 +8575,9 @@ importers:
'@deepseek-ai/dsh-session':
specifier: workspace:^
version: link:../../core/session
+ '@deepseek-ai/dsh-session-projection':
+ specifier: workspace:^
+ version: link:../../session/session-projection
'@deepseek-ai/dsh-subagent':
specifier: workspace:^
version: link:../../subagent/subagent
diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts
index 2f1abb810d..2b8b66160d 100644
--- a/scripts/gen-tool-catalog.ts
+++ b/scripts/gen-tool-catalog.ts
@@ -468,7 +468,6 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(LocalJobRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionStore)
- await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(ToolSubagentControl)
await ctx.plugin(ToolSubagentListAgents)
},
@@ -669,6 +668,7 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
// plugins mounted still tears the context down (no leaked executor/provider
// fiber) — the repo's "dispose must reach quiescence" rule.
try {
+ await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {})
await entry.mount(ctx)
diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json
index 3c7038937c..f8fe47c8bb 100644
--- a/scripts/type-equiv.manifest.json
+++ b/scripts/type-equiv.manifest.json
@@ -628,27 +628,27 @@
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleProviderId",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleModelProvenance",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleSource",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleEventData",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleSnapshot",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",
@@ -658,7 +658,7 @@
{
"doc": "docs/subsystems/session-title.md",
"symbol": "SessionTitleUserMessage",
- "source": "packages/session/session-title/src/index.ts"
+ "source": "packages/session/session-title/src/types.ts"
},
{
"doc": "docs/subsystems/session-title.md",