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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
10
packages/plan/plan-mode/src/client.ts
Normal file
10
packages/plan/plan-mode/src/client.ts
Normal 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'
|
||||
@@ -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({
|
||||
|
||||
28
packages/plan/plan-mode/src/types.ts
Normal file
28
packages/plan/plan-mode/src/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
130
packages/plan/plan-mode/tests/projection.spec.ts
Normal file
130
packages/plan/plan-mode/tests/projection.spec.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
@@ -32,6 +32,9 @@
|
||||
{
|
||||
"path": "../../ui/commands"
|
||||
},
|
||||
{
|
||||
"path": "../../session-projection/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user