diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index 90628f64ee..e2c5244ada 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/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( - ['payload'], vi.fn())} />, + ['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', () => { diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index c86586603d..33b3d1eaca 100644 --- a/packages/plan/plan-mode/README.md +++ b/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 diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 018b32ef4b..731c0a3cdd 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/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 diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 53e4cb11a1..70ed87889d 100644 --- a/packages/plan/plan-mode/package.json +++ b/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:^", diff --git a/packages/plan/plan-mode/src/client.ts b/packages/plan/plan-mode/src/client.ts new file mode 100644 index 0000000000..4c2e281d7b --- /dev/null +++ b/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' diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 4c9c9f6bf4..a876d5e594 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/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 = 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({ diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts new file mode 100644 index 0000000000..eafd5f0aff --- /dev/null +++ b/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 + } +} diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts new file mode 100644 index 0000000000..a9c027530d --- /dev/null +++ b/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 +} + +async function harness(withPlanMode: boolean): Promise { + 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, + } +} + +/** 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 }) + }) +}) diff --git a/packages/plan/plan-mode/tsconfig.json b/packages/plan/plan-mode/tsconfig.json index 6ca0874232..a9b2d4dccf 100644 --- a/packages/plan/plan-mode/tsconfig.json +++ b/packages/plan/plan-mode/tsconfig.json @@ -32,6 +32,9 @@ { "path": "../../ui/commands" }, + { + "path": "../../session-projection/session-projection" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5309a6838..905f0fbe77 100644 --- a/pnpm-lock.yaml +++ b/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 diff --git a/tsconfig.base.json b/tsconfig.base.json index 14cd651ac3..a64ca135ce 100644 --- a/tsconfig.base.json +++ b/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"],