fix(session-projection): close review findings from the fold migration

- permission-presets: register the permissions unit synchronously before the
  existing-session sweep, so a remount reads folded knob state instead of
  treating every session as fresh; add a regression test for that path
- sandbox-policy/terminal-bash: mount the projection registry in the 5 pwsh
  terminal tests, the sdk-minimal bundle, and the e2b fixture composition;
  declare the new package dependency in both manifests
- plan-mode: restore .strict() on the plan unit state schema and drop the
  deleted foldPlanMode from the bilingual READMEs
- session-title/session-projection: sync bilingual READMEs to the mandatory
  projection seam and re-record translation pairing
- docs: turnBoundary reader contract, subagent schema comment, token-meter
  import comment, sandbox-policy module docstring
This commit is contained in:
_Kerman committed 2026-08-26 14:13:42 +08:00
1 parent df0e0960c2
commit a6c7c70d4f
24 files changed
+95 -40

No files matched your search

@@ -33,6 +33,11 @@
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
# Shared projection registry: sandbox-policy and terminal-bash fold
# sandbox-mode state through its units and require it as a hard injection.
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
+1
View File
@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^",
"@deepseek-ai/dsh-session-log-deepseek": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
+2 -1
View File
@@ -34,7 +34,8 @@ export type InboxTarget = 'next-turn' | 'next-step'
* Reader contract: the key is registered by `dsh-agent-loop` and absent
* otherwise. Without agent-loop no turn events exist, so readers treat an
* absent key as "no open turn / no boundaries" — capability absence, not a
* corrupt state — and never treat it as an error.
* corrupt state. A reader whose behavior has no safe fallback for that
* absence (the step-open decision, for example) may fail loud instead.
*/
export interface TurnBoundaryProjection {
/** Seq of the open turn's `turn/start`, or null between turns. */
+1
View File
@@ -50,6 +50,7 @@
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-subprocess-e2b": "workspace:^",
"@deepseek-ai/dsh-terminal": "workspace:^",
"@deepseek-ai/dsh-terminal-bash": "workspace:^",
@@ -14,6 +14,7 @@ import {
import TerminalSessionService, { TerminalSessionId } from '@deepseek-ai/dsh-terminal'
import { BashTerminalBackend } from '@deepseek-ai/dsh-terminal-bash'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import E2BSubprocessRuntime from '@deepseek-ai/dsh-subprocess-e2b'
@@ -52,6 +53,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
runtimeRoot: '/home/user/.dsh-e2b',
getSandbox: async () => sandbox,
} as never)
await ctx.plugin(SessionProjectionRegistry)
const sandboxPolicyFiber = await ctx.plugin(SandboxPolicyService, {
mode: 'danger-full-access',
workspaceRoot: '/home/user',
@@ -21,6 +21,9 @@
- id: agents
name: '@deepseek-ai/dsh-agent'
- id: session-projection
name: '@deepseek-ai/dsh-session-projection'
- id: sandbox-policy
name: '@deepseek-ai/dsh-sandbox-policy'
config:
@@ -189,13 +189,6 @@ export class PermissionPresetService extends Service {
onChange: () => {},
})
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
// 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
@@ -209,18 +202,25 @@ export class PermissionPresetService extends Service {
currentValue: zod.string().min(1),
}) as unknown as zod.ZodType<PermissionSelect>
// The `permissions` projection unit folds the three whole-value knob
// events; it registers through the projection registry.
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register({
key: 'permissions',
stateVersion: 1,
stateSchema: knobStateSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
})
// events. `sessionProjections` is a hard injection, so the registration is
// synchronous and lands before the sweep below; otherwise the sweep would
// read an unregistered key and treat every existing session as fresh.
ctx.sessionProjections.register({
key: 'permissions',
stateVersion: 1,
stateSchema: knobStateSchema,
init: () => EMPTY_KNOBS,
apply: applyKnobEvent,
wire: { viewSchema: selectSchema, view: state => this.selectFor(state) },
})
ctx.on('session/created', (session) => {
this.pinInitialPermission(session)
})
for (const session of ctx.sessions.list()) {
this.pinInitialPermission(session)
}
// The /permission command: the one write path a web client uses (the
// popup contribution submits the picked preset as this line). The child
// activates only when a command registry is composed.
@@ -267,6 +267,31 @@ describe('new-session default', () => {
expect(ctx.permissionPresets.current(existing)).toBe('workspace-write')
})
it('preserves existing knob overrides when the service remounts over a knob-bearing session', 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') },
run() { throw new Error('permission tests do not execute bash') },
start() { throw new Error('permission tests do not execute bash') },
})
ctx.provide('approval', { config: { policy: 'ask' } })
const existing = ctx.sessions.create(SessionId('existing-knobs'))
existing.append('sandbox/mode', { mode: 'read-only' })
existing.append('approval/policy', { policy: 'never' })
await ctx.plugin(PermissionPresetService, {})
// The remount sweep must read the folded knob events instead of treating
// the session as fresh; no default preset events may overwrite the
// overrides (read-only + never matches no preset table entry).
expect(existing.events.map(event => event.type)).toEqual([
'sandbox/mode', 'approval/policy',
])
expect(ctx.permissionPresets.current(existing)).toBe(CUSTOM_PRESET)
})
it('fills only missing legacy facts and preserves an unmatched seeded combination', async () => {
const ctx = await mountedStore()
const partial = freshSession('partial-source')
+1
View File
@@ -10,6 +10,7 @@ import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { LlmImageRequestPricing, 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: activates the `ctx.sessionProjections` Context declaration.
import type {} from '@deepseek-ai/dsh-session-projection'
import type {
TokenMeasurement,
+2 -2
View File
@@ -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: 14c24507accb642909c1c9ed050368d9fa9120f1
README.zh.md: 716f22a7b5bf7970b1eae429155d669805ce7d00
README.md: 063e91e40fb55ea24e4fbfcefc984cd581d065b3
README.zh.md: 8da8a7b3e01c42e619f882105dfe9e99b891a918
+2 -2
View File
@@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac
### Durable state and step-boundary appends
The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get`/`foldPlanMode` helpers and their exact return states live in [`src/index.ts`](src/index.ts).
The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts); the registered `plan` projection unit folds the same state for readers.
### The `/plan` command
@@ -94,7 +94,7 @@ The command child activates only when a commands service is composed. It maps ba
### Session projection unit
When `ctx.sessionProjections` is composed, the package registers the `plan` unit under an injected child. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key.
The package requires `ctx.sessionProjections` and registers the `plan` unit on activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key.
### Source map
+2 -2
View File
@@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
### 持久状态与步骤边界追加
本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get`/`foldPlanMode` 辅助函数及其确切返回状态见 [`src/index.ts`](src/index.ts)。
本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts);已注册的 `plan` 投影单元为读取方折叠同一状态。
### `/plan` 命令
@@ -94,7 +94,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并
### 会话投影单元
组合 `ctx.sessionProjections` 时,本包在一个注入的子插件中注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。
本包要求 `ctx.sessionProjections` 并在激活时注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。
### 源码地图
+6 -3
View File
@@ -113,12 +113,15 @@ export function resolveConfig(config: PlanModeConfig): PlanModeConfig {
return { section }
}
const planUnitStateSchema = zod.object({
const planUnitStateSchema: ZodType<PlanUnitState> = zod.object({
active: zod.boolean(),
wanted: zod.boolean().nullable(),
running: zod.object({ commandId: zod.string() as unknown as ZodType<CommandId>, wanted: zod.boolean() }).nullable(),
running: zod.object({
commandId: zod.string() as unknown as ZodType<CommandId>,
wanted: zod.boolean(),
}).strict().nullable(),
activeAtLastHeader: zod.boolean().nullable(),
})
}).strict()
/** Wire payload schema of the `plan` projection. */
const planProjectionSchema: ZodType<PlanProjection> = zod.object({
+3 -1
View File
@@ -2,7 +2,9 @@
* The sandbox POLICY home (`ctx.sandboxPolicy`): the single owner of the
* deployment's sandbox fallbacks plus per-session resolution: the file-effect
* {@link SandboxMode}, the `workspace-write` root, and the override kit (the
* `sandbox/mode` event, its fold, and its write path, from `./session-mode.ts`).
* `sandbox/mode` event, its fold, and its write path; the fold is the
* `sandboxMode` session-projection unit registered here, while the event and
* write path come from `./session-mode.ts`).
* Before each agent request, the owner also contributes the resolved policy to
* the cache-safe runtime-context snapshot. The agent loop logs that snapshot as
* model history, so replay reconstructs the same mode and root the enforcing
@@ -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: 95ee09236966c8cbfdaf79cdcb717e04ee0ec367
README.zh.md: 0402fbe8e3c56e44f7597bb14fb0379843ecdbca
README.md: 2ef45b42390227d4f5367ea2a4c6023b9e24b5d8
README.zh.md: 59f0849f5d79b32bb1b5a11ca25aa9af2593ee7f
@@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l
### When to choose it
Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only, and headless assemblies without the registry are unaffected.
Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only. Unit contributors and readers declare `sessionProjections` in their plugin `inject`, so a composition without the registry fails activation instead of silently degrading.
### Define a projection unit
@@ -29,7 +29,7 @@ kind: "package-reference"
### 何时选择
当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only,而不带注册表的 headless 组装不受影响。
当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only。单元贡献方与读取方在插件 `inject` 中声明 `sessionProjections`,因此不带注册表的组合会在激活时失败,而不是静默降级。
### 定义投影单元
@@ -171,9 +171,10 @@ 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 declare `sessionProjections` in their plugin `inject`, so a
* composition without the registry fails activation instead of silently
* degrading. 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.
@@ -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: 12335afc1eccd7c9b9f4a33d72549df296cbfb1b
README.zh.md: e61297ed555c93273efc108221cb0141a9c997b7
README.md: debc737e3f572f15856f7f78a58abe8532ae15dc
README.zh.md: cf8079cd5566023f3dc1da8480eeec5327eebb5a
+1 -1
View File
@@ -58,7 +58,7 @@ One optional asynchronous provider may be registered through `ctx.sessionTitle.r
### Reading titles
`get(session)` folds the latest accepted title from the live or replayed log, and `foldSessionTitle(events)` is the pure fold over a log. The service also registers a `title` projection unit — the plain title string — for client list rows when a projection registry is composed. An explicit `refresh(session)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages.
`get(session)` reads the latest folded title from one live or replayed session, and `foldSessionTitle(events)` is the pure fold over a log. The service requires `ctx.sessionProjections` and registers two units: the client-visible `title` unit (the accepted title string for client list rows) and the host-only `titleInput` unit, which folds the first and latest eligible messages plus their count so scheduling and fallback reads are O(1) through `stateOf()`; the full eligible prefix for one provider generation is scanned from the session log at execution time. An explicit `refresh(session)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages.
### Failures and recovery
+1 -1
View File
@@ -58,7 +58,7 @@ kind: "package-reference"
### 读取标题
`get(session)` 从活跃或回放日志折叠最新已接受标题,`foldSessionTitle(events)` 是对日志的纯折叠。服务还会在组合了投影注册表时注册一个 `title` 投影单元——纯标题字符串——供客户端列表行使用。显式 `refresh(session)` 在需要时物化回退,然后对当前符合条件的消息显式运行已注册提供方。
`get(session)` 从活跃或回放会话读取折叠出的最新标题,`foldSessionTitle(events)` 是对日志的纯折叠。服务要求 `ctx.sessionProjections` 并注册两个单元:客户端可见的 `title` 单元(供客户端列表行使用的已接受标题字符串)和仅供 host 使用的 `titleInput` 单元——后者折叠第一条与最新一条合格消息及其计数,使调度与回退读取通过 `stateOf()` 达到 O(1);某次提供方生成所需的完整合格前缀,则会在执行时从会话日志中扫描取得。显式 `refresh(session)` 在需要时物化回退,然后对当前符合条件的消息显式运行已注册提供方。
### 失败与恢复
@@ -41,7 +41,6 @@ const timingStateSchema: z.ZodType<TimingState> = z.object({
settledMs: z.number().int().nonnegative(),
active: activeIntervalSchema.optional(),
pendingTurnStart: z.number().int().nonnegative().optional(),
/** Whether the fold has crossed a descriptor in this logical log. */
descriptorSeen: z.boolean(),
}).strict()
@@ -354,6 +354,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('bootstraps a pwsh dialect through the prompt function and scrubs bash-only env', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
let spawned: SubprocessTerminalSpawnSpec | undefined
let sent: TerminalSendRequest | undefined
@@ -391,6 +392,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('keeps waiting for stdin_read when the first settled output only echoes the prompt literal', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
const sends: TerminalSendRequest[] = []
const session = {
@@ -425,6 +427,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('rejects a pwsh bootstrap whose shell exits or times out', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
const sessionFor = (waitReason: TerminalWaitReason): LocalPtySession => ({
startSend: () => ({
@@ -449,6 +452,7 @@ describe('BashTerminalBackend startup rollback', () => {
try {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
const pending = Promise.withResolvers<{
viewport: string
@@ -501,6 +505,7 @@ describe('BashTerminalBackend startup rollback', () => {
it('forwards the spawn signal into the pwsh bootstrap sends', async () => {
const ctx = new Context()
await ctx.plugin(EmptySandbox)
await ctx.plugin(SessionProjectionRegistry)
await ctx.plugin(SandboxPolicyService, { mode: 'danger-full-access', workspaceRoot: '/workspace' })
const sends: TerminalSendRequest[] = []
const session = {
+6
View File
@@ -1345,6 +1345,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-subprocess-local':
specifier: workspace:^
version: link:../../subprocess/subprocess-local
@@ -4453,6 +4456,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-e2b':
specifier: workspace:^
version: link:../subprocess-e2b