feat(plan): register the plan session-projection unit

plan-mode contributes the RFC's complete double-event example: command/run
records named plan set the wanted target (off -> false, else true),
plan/mode commits and clears it, and view derives {active, pending}.
Pending is thereby a pure replay quantity recovered from the log alone.
The plan key merges into SessionProjectionMap from src/types.ts, served
through the new ./types and ./client outlets (session-title template);
compositions without the registry are unaffected.
This commit is contained in:
imccyu
2026-07-28 20:46:46 +08:00
parent 2ecc89a186
commit f0f1966c97
11 changed files with 310 additions and 4 deletions

View File

@@ -18,6 +18,10 @@ When `ctx.commands` is composed, the package registers `/plan [message]` and res
The TUI consumes the plugin-owned `/plan` command; other front doors may drive the same service directly without defining a second mode vocabulary.
## Session projection
When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). 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.
## Configuration
```yaml

View File

@@ -18,6 +18,10 @@
TUI 消费插件拥有的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
## 会话投影
当组合挂载 `ctx.sessionProjections`[`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.zh.md))时,本包在注入子插件下注册 `plan` 投影单元。该单元折叠两种事件:名为 `plan``command/run` 记录设置目标值(`off` → 未激活,其余 → 激活),`plan/mode` 提交已记录状态并将其清除;其他任何事件返回同一状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在未兑现的选择不同于已记录状态时为 true——它是纯回放量host 重启、其他标签页与冷读都只凭日志即可恢复(`/plan` 处理器在任何可能失败的路径之前调用 `set()`使已入日志的请求与运行面不可能分叉。key 从 `src/types.ts` merge 进 `SessionProjectionMap`host 消费方经 `./types`、client 聚合经 `./client`);框架驱动单元,载体在历史尾页与 `session/projection` 推送帧上提供该值。未挂注册表的组合不受影响。
## 配置
```yaml

View File

@@ -15,12 +15,21 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client.d.ts",
"default": "./lib/types/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -31,6 +40,7 @@
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
@@ -41,6 +51,9 @@
"optional": true
}
},
"dependencies": {
"zod": "^4.4.3"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
@@ -49,6 +62,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",

View File

@@ -0,0 +1,10 @@
/**
* Client-namespace projection of the plan domain: a pure re-export of the package's
* types outlet. Client code imports ONLY the client namespace (repo
* discipline), so `./client` projects the same single-source content
* `./types` serves to host consumers — zero duplication.
*
* @module @deepseek-ai/dsh-plan-mode/client
*/
export type * from './types.ts'

View File

@@ -22,6 +22,8 @@
*/
import { Context, Service } from 'cordis'
import { z as zod } from 'zod'
import type { ZodType } from 'zod'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -30,6 +32,14 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-interaction'
// 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.
export type * from './types.ts'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
@@ -122,6 +132,23 @@ export function foldPlanMode(events: readonly SessionEvent[], end = events.lengt
return active
}
/**
* Projection unit state: the logged mode plus the latest logged `/plan`
* selection (`command/run`) not yet resolved by a `plan/mode` commit. Plain
* JSON (persisted-cache precondition).
*/
interface PlanUnitState {
active: boolean
/** The selection's target mode; null when no selection is outstanding. */
wanted: boolean | null
}
/** Wire payload schema of the `plan` projection. */
const planProjectionSchema: ZodType<PlanProjection> = zod.object({
active: zod.boolean(),
pending: zod.boolean(),
})
/** Plan state at the last logged request header, or `undefined` before the first header. */
function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefined {
let lastHeader = -1
@@ -182,6 +209,37 @@ export class PlanModeService extends Service {
: '',
})
// The plan projection unit (session-projection RFC): a pure double-event
// fold serving clients the whole {active, pending} value. `command/run`
// records the user's logged /plan selection (the handler calls `set()`
// before any failing path, so log and run-plane cannot fork); `plan/mode`
// is the boundary commit that resolves 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',
schema: planProjectionSchema,
init: () => ({ active: false, wanted: null }),
apply: (state, event) => {
if (event.type === 'command/run' && event.data.name === 'plan') {
const wanted = event.data.args.trim() !== 'off'
return wanted === state.wanted ? state : { active: state.active, wanted }
}
if (event.type === 'plan/mode') {
return { active: event.data.active, wanted: null }
}
return state
},
view: state => ({
active: state.active,
pending: state.wanted !== null && state.wanted !== state.active,
}),
stateVersion: 1,
})
})
// The command child activates only when a command registry is composed.
ctx.inject(['commands'], (commandCtx) => {
commandCtx.commands.register({

View File

@@ -0,0 +1,28 @@
/**
* 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.
*
* @module @deepseek-ai/dsh-plan-mode/types
*/
/**
* The plan projection's wire value. `active` is the logged state in force
* (the last `plan/mode`, inactive before the first); `pending` is true while
* a logged `/plan` selection (`command/run`) awaits its request-boundary
* `plan/mode` commit and targets a state other than `active`. Capability
* absence (plan-mode not composed) is the key's absence, never a value.
*/
export interface PlanProjection {
active: boolean
pending: boolean
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Plan collaboration state folded from `command/run` (name `plan`) and `plan/mode` events. */
plan: PlanProjection
}
}

View File

@@ -0,0 +1,130 @@
/**
* The `plan` projection unit (session-projection RFC's complete example): a
* double-event fold over the session log. `command/run` records named `plan`
* set the wanted target (`off` → false, anything else → true); `plan/mode`
* commits and clears it; `view` derives `{ active, pending }` where pending
* is true 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).
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { CommandId } from '@deepseek-ai/dsh-commands/brand'
import PlanModeService from '@deepseek-ai/dsh-plan-mode'
interface Bench {
ctx: Context
session: Session
values(): Record<string, unknown>
}
async function harness(withPlanMode: boolean): Promise<Bench> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt, { persona: '' })
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(SessionProjectionRegistry)
if (withPlanMode) await ctx.plugin(PlanModeService, { section: 'plan policy' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
return {
ctx,
session,
values: () => ctx.sessionProjections.snapshot(session).values as Record<string, unknown>,
}
}
/** Append one logged /plan selection record (the executor's command/run shape). */
function runPlanCommand(session: Session, args: string, index: number): void {
session.append('command/run', {
commandId: CommandId(`plan-proj-${String(index)}`),
name: 'plan',
args,
source: { kind: 'user' },
})
}
/** Commit one plan/mode flip inside an open turn (the invariant's turn-enclosure rule). */
function commitPlanMode(session: Session, active: boolean, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('plan/mode', { active })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
describe('plan projection unit', () => {
it('serves inactive/not-pending for the empty log', async () => {
const bench = await harness(true)
expect(bench.values()).toEqual({ plan: { active: false, pending: false } })
})
it('a logged /plan selection reads pending until the boundary commit resolves it', async () => {
const bench = await harness(true)
runPlanCommand(bench.session, '', 0)
expect(bench.values().plan).toEqual({ active: false, pending: true })
commitPlanMode(bench.session, true, 0)
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)
// Another command's record never touches plan state.
bench.session.append('command/run', {
commandId: CommandId('other-1'), name: 'compact', args: '', source: { kind: 'user' },
})
expect(bench.values().plan).toEqual({ active: true, pending: false })
runPlanCommand(bench.session, ' off', 1)
expect(bench.values().plan).toEqual({ active: true, pending: true })
commitPlanMode(bench.session, false, 1)
expect(bench.values().plan).toEqual({ active: false, pending: false })
// Selecting the already-committed state folds to not-pending (net zero).
runPlanCommand(bench.session, 'off', 2)
expect(bench.values().plan).toEqual({ active: false, pending: false })
})
it('a /plan message-argument selection targets plan mode', async () => {
const bench = await harness(true)
runPlanCommand(bench.session, ' sketch the refactor first', 0)
expect(bench.values().plan).toEqual({ active: false, pending: true })
})
it('has no plan key when plan-mode is not composed', async () => {
const bench = await harness(false)
expect('plan' in bench.values()).toBe(false)
})
it('drops the key when the plan-mode fiber unloads (HMR safety)', async () => {
const bench = await harness(false)
const fiber = await bench.ctx.plugin(PlanModeService, { section: 'plan policy' })
expect(bench.values().plan).toEqual({ active: false, pending: false })
await fiber.dispose()
expect('plan' in bench.values()).toBe(false)
})
it('cold replay recovers pending from the log alone (a fresh registry refolds it)', async () => {
const bench = await harness(true)
runPlanCommand(bench.session, '', 0)
// A second registry over the same log (the cold-read shape): no service
// 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 === 'plan/mode') {
cold.session.append(event.type, event.data as never)
}
}
expect(cold.values().plan).toEqual({ active: false, pending: true })
})
})

View File

@@ -32,6 +32,9 @@
{
"path": "../../ui/commands"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../support/invariants"
}