Преглед на файлове

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.
imccyu преди 2 месеца
родител
ревизия
f0f1966c97

+ 4 - 4
packages/client/ui-conversation/tests/coverage-tails.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 // Branch tails the acceptance specs do not reach: ToolRow stopped-state dot,
-// PendingCard question arm, bash sample state dots, the node-half empty
+// PendingCard approval wait, bash sample state dots, the node-half empty
 // apply, and AssistantMarkdown reasoning/unknown block arms.
 
 import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -33,11 +33,11 @@ describe('tails', () => {
     expect(view.container.querySelector('[data-state="stopped"]')).not.toBeNull()
   })
 
-  it('PendingCard renders the question arm with its count', () => {
+  it('PendingCard renders the approval wait with its tool name', () => {
     const view = render(
-      <PendingCard item={new PendingWait('question', RpcId('r1'), 's1' as SessionId, { questions: [{}, {}] } as PendingWait<'question'>['payload'], vi.fn())} />,
+      <PendingCard item={new PendingWait('approval', RpcId('r1'), 's1' as SessionId, { toolName: 'bash' } as PendingWait<'approval'>['payload'], vi.fn())} />,
     )
-    expect(view.getByText(/等待回答(2 题)/)).toBeTruthy()
+    expect(view.getByText(/等待审批/)).toBeTruthy()
   })
 
   it('AssistantMarkdown renders reasoning as a Think row and unknown blocks as JSON fallback', () => {

+ 4 - 0
packages/plan/plan-mode/README.md

@@ -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

+ 4 - 0
packages/plan/plan-mode/README.zh.md

@@ -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

+ 14 - 0
packages/plan/plan-mode/package.json

@@ -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 - 0
packages/plan/plan-mode/src/client.ts

@@ -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'

+ 58 - 0
packages/plan/plan-mode/src/index.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 - 0
packages/plan/plan-mode/src/types.ts

@@ -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 - 0
packages/plan/plan-mode/tests/projection.spec.ts

@@ -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 })
+  })
+})

+ 3 - 0
packages/plan/plan-mode/tsconfig.json

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

+ 53 - 0
pnpm-lock.yaml

@@ -161,6 +161,9 @@ importers:
       '@deepseek-ai/dsh-client-ui-models':
         specifier: workspace:^
         version: link:../../packages/client/ui-models
+      '@deepseek-ai/dsh-client-ui-plan':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-plan
       '@deepseek-ai/dsh-client-ui-question':
         specifier: workspace:^
         version: link:../../packages/client/ui-question
@@ -1091,6 +1094,49 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1
 
+  packages/client/ui-plan:
+    dependencies:
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-conversation':
+        specifier: workspace:^
+        version: link:../ui-conversation
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-plan-mode':
+        specifier: workspace:^
+        version: link:../../plan/plan-mode
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+    devDependencies:
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
+      '@deepseek-ai/dsh-client-web-react':
+        specifier: workspace:^
+        version: link:../web-react
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@deepseek-ai/dsh-system-prompt':
+        specifier: workspace:^
+        version: link:../../core/system-prompt
+      '@deepseek-ai/dsh-tools':
+        specifier: workspace:^
+        version: link:../../core/tools
+      '@deepseek-ai/dsh-user-interaction':
+        specifier: workspace:^
+        version: link:../../ui/user-interaction
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
+
   packages/client/ui-primitives:
     dependencies:
       '@shikijs/langs':
@@ -2997,6 +3043,10 @@ importers:
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)
 
   packages/plan/plan-mode:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
     devDependencies:
       '@deepseek-ai/dsh-agent':
         specifier: workspace:^
@@ -3019,6 +3069,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-session-projection':
+        specifier: workspace:^
+        version: link:../../session-projection/session-projection
       '@deepseek-ai/dsh-system-prompt':
         specifier: workspace:^
         version: link:../../core/system-prompt

+ 2 - 0
tsconfig.base.json

@@ -46,6 +46,8 @@
       "@deepseek-ai/dsh-tool-todo/client": ["./packages/todo/tool-todo/src/client.ts"],
       "@deepseek-ai/dsh-session-title/types": ["./packages/session-title/session-title/src/types.ts"],
       "@deepseek-ai/dsh-session-title/client": ["./packages/session-title/session-title/src/client.ts"],
+      "@deepseek-ai/dsh-plan-mode/types": ["./packages/plan/plan-mode/src/types.ts"],
+      "@deepseek-ai/dsh-plan-mode/client": ["./packages/plan/plan-mode/src/client.ts"],
       "@deepseek-ai/dsh-llm/types": ["./packages/llm/llm/src/types.ts"],
       "@deepseek-ai/dsh-llm/brand": ["./packages/llm/llm/src/brand.ts"],
       "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"],