diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml index c972b19115..24a944d6e6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.i18n.yaml @@ -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 -2026-07-24-web-plan-mode.md: 8ed22fa3833b3903dfaeb34c8c7810267aef9930 -2026-07-24-web-plan-mode.zh.md: 9647e3d71bcc9132bc0990e7c296a63cdab55812 +2026-07-24-web-plan-mode.md: 395ef2a3afcdac53c5d97b5555c2ffd635497db2 +2026-07-24-web-plan-mode.zh.md: 21727052c284805b38d411e0215af876b2986e35 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md index 8ed22fa383..395ef2a3af 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.md @@ -14,7 +14,7 @@ Plan selection is not a stop command. The plan service deliberately queues the l `@deepseek-ai/dsh-client-ui-plan` is one Web feature plugin with lifecycle-coupled host and browser entries. Its host entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product's complete planning policy. Its browser entry registers `PlanModeControl` into the session-scoped `conversation.composer.controls` list slot. The `dsh web` roster selects the package once; plugin discovery loads the browser entry while the same roster mount supplies the host behavior. -The policy is product-owned configuration at this composition boundary. It tells the model to inspect before planning, avoid mutation while planning, resolve discoverable facts without asking, make the plan decision-complete, and submit it as the only final `exit_plan_mode` call. The plan package continues to own logged state, boundary timing, prompt-section activation, the stable exit-tool schema, and review semantics. The Web plugin does not copy any of those mechanisms. +The policy is product-owned configuration at this composition boundary. Its active section tells the model to inspect before planning, avoid mutation while planning, resolve discoverable facts without asking, make the plan decision-complete, and submit it as the only final `exit_plan_mode` call. Its complementary default section explicitly says that the session is no longer in plan mode, permits normal implementation work, and forbids calling the still-registered exit tool. This current-state statement prevents earlier conversation text about plan mode from confusing a later default request. The plan package continues to own logged state, boundary timing, the active prompt-section decision, the stable exit-tool schema, and review semantics. The Web plugin does not copy those mechanisms. `ui-conversation` owns and renders the new additive controls slot to the left of the primary composer action. It provides no business payload; entries receive the standard session kit. Whole-composer replacements remain on the separate selector-routed `conversation.composer` chain, so a pending question replaces the InputBar and its controls without either feature importing the other. @@ -32,6 +32,8 @@ The control is absent when `planMode` is `null`. Otherwise, its selected value i Business and transport failures leave the confirmed snapshot unchanged, re-enable the selector, and render a compact visible failure beside it. The component guards asynchronous completion after unmount so switching sessions cannot update a retired control. +Sending waits for the latest selector request and any newer selection that supersedes it. During that admission-only interval, the composer clears its draft and disables duplicate sends; it does not wait for model generation. Host acceptance releases the admission lock, after which the ordinary running state keeps Stop available. A selection or admission failure sends no prompt and restores the submitted draft only when replacement text has not appeared. + ## Exit review `exit_plan_mode` remains registered in both modes for request-cache stability. In plan mode the model submits the complete Markdown plan through that tool. The plan service asks through `ctx.userInteraction`, and the already-composed Web question plugin presents the plan detail with Approve, Keep planning, and the free-text answer channel. Question detail reuses the assistant-output Markdown primitive and its untrusted-content policy. The capped question card keeps its title, navigation, and submission actions fixed while the complete plan and choices share an internal scroll region. The chat flow omits its generic pending-question placeholder because the composer takeover is the sole presentation of that wait. @@ -40,7 +42,7 @@ Approval queues inactive mode for the next step; it does not rewrite the current ## Product composition and evidence -Fixture mode implements the same pending and boundary behavior in memory so browser acceptance tests exercise the assembled product without a key. The keyless browser flow selects Plan, commits it with a prompt, selects Default during generation, stops without losing the pending target, and commits Default with the next prompt. A file snapshot records each user-visible state. A real `dsh web` process with a mock provider additionally proves that the roster mounts plan mode, the state RPC reports capability, and the active Web policy reaches the provider request alongside workspace instructions. +Fixture mode implements the same pending and boundary behavior in memory so browser acceptance tests exercise the assembled product without a key. The keyless browser flow selects Plan, commits it with a prompt, selects Default during generation, stops without losing the pending target, and commits Default with the next prompt. A file snapshot records each user-visible state. A real `dsh web` process with a mock provider additionally proves that the roster mounts plan mode, the state RPC reports capability, and both exact-mode Web policies reach their corresponding provider requests alongside workspace instructions. ## Alternatives considered @@ -56,4 +58,4 @@ Fixture mode implements the same pending and boundary behavior in memory so brow ## Consequences -The Web product now exposes the same plan interaction model as the current terminal and ACP compositions while retaining its plugin boundaries. Selecting the feature adds one host policy/tool owner and one browser slot entry; removing its fiber removes both. The model tool catalog stays stable across mode changes, but the active system-prompt section changes at a plan boundary and therefore changes the request prefix. Plan mode remains guidance, not an execution sandbox: deployments that require enforced read-only planning still compose the independent sandbox and approval policies. +The Web product now exposes the same plan interaction model as the current terminal and ACP compositions while retaining its plugin boundaries. Selecting the feature adds one host policy/tool owner and one browser slot entry; removing its fiber removes both. The model tool catalog stays stable across mode changes, but the active Web mode section changes at a plan boundary and therefore changes the request prefix. Plan mode remains guidance, not an execution sandbox: deployments that require enforced read-only planning still compose the independent sandbox and approval policies. diff --git a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md index 9647e3d71b..21727052c2 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-plan-mode.zh.md @@ -14,7 +14,7 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p `@deepseek-ai/dsh-client-ui-plan` 是一个 Web 功能插件,包含生命周期耦合的宿主入口和浏览器入口。宿主入口使用 Web 产品的完整 plan 策略挂载 `@deepseek-ai/dsh-plan-mode`。浏览器入口则把 `PlanModeControl` 注册到会话作用域的 `conversation.composer.controls` 列表槽。`dsh web` 的插件清单只需选择该包一次:插件发现机制会加载浏览器入口,同一次清单挂载则提供宿主行为。 -该策略是此组合边界上由产品拥有的配置。它要求模型先检查再规划、规划期间避免修改、无需询问即可自行查明能够发现的事实、使 plan 包含完成决策所需的全部信息,并把 plan 作为唯一且最终的 `exit_plan_mode` 调用提交。plan 包(package)继续拥有已记录的状态、边界时序、提示词段激活、稳定的退出工具 schema 和评审语义。Web 插件不会复制其中任何机制。 +该策略是此组合边界上由产品拥有的配置。其激活段要求模型先检查再规划、规划期间避免修改、无需询问即可自行查明能够发现的事实、使 plan 包含完成决策所需的全部信息,并把 plan 作为唯一且最终的 `exit_plan_mode` 调用提交。与之配套的默认段会明确说明会话已不处于 plan mode,允许正常实施工作,并禁止调用仍保持注册的退出工具。此项当前状态声明可避免对话中较早出现的 plan mode 文本干扰之后的默认模式请求。plan 包(package)继续拥有已记录的状态、边界时序、提示词段激活判定、稳定的退出工具 schema 和评审语义。Web 插件不会复制这些机制。 `ui-conversation` 拥有并渲染新增的可叠加控件槽,其位置在 composer 主操作左侧。该槽不提供业务载荷;各入口接收标准会话注入项。替换整个 composer 的功能仍走另一条由选择器路由的 `conversation.composer` 链,因此待处理的问题会替换 InputBar 及其控件,两个功能均无需导入对方。 @@ -32,6 +32,8 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p 业务故障和传输故障都会保持已确认的快照不变,重新启用选择器,并在其旁边渲染一条紧凑且可见的失败信息。组件会防止卸载后完成的异步操作继续更新状态,因此切换会话不会更新已经退出使用的控件。 +发送操作会等待最近一次选择器请求,以及此后任何取代该请求的更新选择。在这个仅涵盖受理阶段的区间内,composer 会清空草稿并禁用重复发送;它不会等待模型生成。宿主受理请求即释放受理锁,此后普通的运行状态仍会使「停止」可用。选择或受理失败时不会发送提示词,并且仅当输入框尚未出现替代文本时,才会恢复已提交的草稿。 + ## 退出评审 `exit_plan_mode` 在两种模式下都会保持注册,以维持请求缓存稳定性。在 plan mode 中,模型通过该工具提交完整的 Markdown plan。plan 服务经由 `ctx.userInteraction` 发起询问,已组合进 Web 的问题插件会展示 plan 详情,并提供「批准」、「继续规划」和自由文本回答渠道。问题详情会复用 assistant 输出所用的 Markdown 基础组件及其不受信任内容策略。设有高度上限的问题卡片会固定显示标题、导航操作和提交操作,而完整 plan 与选项共享同一个内部滚动区域。由于 composer 接管是该等待状态的唯一呈现方式,聊天流程不会渲染通用的待处理问题占位块。 @@ -40,7 +42,7 @@ Web 宿主可以通过[可选会话 RPC 契约](../architecture/2026-07-24-web-p ## 产品组合与证据 -fixture(测试前置数据)模式在内存中实现相同的待生效与边界行为,因此浏览器验收测试无需密钥即可覆盖组合后的产品。无密钥浏览器流程依次选择「计划」、通过提示词提交该模式、在生成期间选择「默认」、停止生成且不丢失待生效目标,再通过下一条提示词提交「默认」。文件快照记录每个用户可见状态。另一个使用 mock 提供方的真实 `dsh web` 进程还证明:插件清单会挂载 plan mode,状态 RPC 会报告该功能,并且激活的 Web 策略会随工作区指令一同进入提供方请求。 +fixture(测试前置数据)模式在内存中实现相同的待生效与边界行为,因此浏览器验收测试无需密钥即可覆盖组合后的产品。无密钥浏览器流程依次选择「计划」、通过提示词提交该模式、在生成期间选择「默认」、停止生成且不丢失待生效目标,再通过下一条提示词提交「默认」。文件快照记录每个用户可见状态。另一个使用 mock 提供方的真实 `dsh web` 进程还证明:插件清单会挂载 plan mode,状态 RPC 会报告该功能,并且 Web 针对两种确切模式的策略会随工作区指令分别进入对应的提供方请求。 ## 考虑过的替代方案 @@ -56,4 +58,4 @@ fixture(测试前置数据)模式在内存中实现相同的待生效与边 ## 后果 -Web 产品现在会在保留插件边界的同时,公开与现有终端和 ACP(Agent Client Protocol)产品组合相同的 plan 交互模型。选择该功能会增加一个宿主策略/工具所有者和一个浏览器槽入口;移除其 fiber 会同时移除二者。模型工具目录在模式切换期间保持稳定,但激活的系统提示词段会在 plan 边界发生变化,因此请求前缀也会改变。Plan mode 仍是引导机制,而非执行沙箱:需要强制只读规划的部署仍需组合彼此独立的沙箱策略和审批策略。 +Web 产品现在会在保留插件边界的同时,公开与现有终端和 ACP(Agent Client Protocol)产品组合相同的 plan 交互模型。选择该功能会增加一个宿主策略/工具所有者和一个浏览器槽入口;移除其 fiber 会同时移除二者。模型工具目录在模式切换期间保持稳定,但激活的 Web 模式段会在 plan 边界发生变化,因此请求前缀也会改变。Plan mode 仍是引导机制,而非执行沙箱:需要强制只读规划的部署仍需组合彼此独立的沙箱策略和审批策略。 diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index a12cfb4392..98e2369174 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -193,22 +193,30 @@ describe('dsh web keyless CLI smoke', () => { } }) - it('injects workspace instructions and the active Web plan policy into the provider request', async () => { + it('injects workspace instructions and the exact Web mode policy into provider requests', async () => { requireDist() const workspace = mkdtempSync(join(tmpdir(), 'dsh-web-workspace-')) mkdirSync(join(workspace, '.git')) writeFileSync(join(workspace, 'AGENTS.md'), 'web-workspace-context-probe\n') - let resolveProviderRequest!: (request: { messages?: { role?: string; content?: string }[] }) => void - const providerRequest = new Promise<{ messages?: { role?: string; content?: string }[] }>((resolve) => { - resolveProviderRequest = resolve + type ProviderRequest = { messages?: { role?: string; content?: string }[] } + let resolveDefaultRequest!: (request: ProviderRequest) => void + let resolvePlanRequest!: (request: ProviderRequest) => void + const defaultRequest = new Promise((resolve) => { + resolveDefaultRequest = resolve + }) + const planRequest = new Promise((resolve) => { + resolvePlanRequest = resolve }) const provider = createServer((request, response) => { let body = '' request.setEncoding('utf8') request.on('data', (chunk: string) => { body += chunk }) request.on('end', () => { - resolveProviderRequest(JSON.parse(body) as { messages?: { role?: string; content?: string }[] }) + const parsed = JSON.parse(body) as ProviderRequest + const system = parsed.messages?.find(message => message.role === 'system')?.content ?? '' + if (system.includes('You are in default mode, not plan mode.')) resolveDefaultRequest(parsed) + if (system.includes('Stay in plan mode until exit_plan_mode succeeds')) resolvePlanRequest(parsed) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.end([ 'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}', @@ -241,25 +249,43 @@ describe('dsh web keyless CLI smoke', () => { try { const baseUrl = await waitForReadyLine(child) const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {}) - expect(await rpc(baseUrl, 'session.setPlanMode', { - sessionId: created.sessionId, active: true, - })).toEqual({ active: false, pending: true }) await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { sessionId: created.sessionId, mode: 'queue', - content: [{ type: 'text', text: 'go' }], + content: [{ type: 'text', text: 'default request' }], + planMode: false, }) - const captured = await Promise.race([ - providerRequest, + const capturedDefault = await Promise.race([ + defaultRequest, new Promise((_resolve, reject) => { - setTimeout(() => { reject(new Error('provider request not received in 10s')) }, 10_000).unref() + setTimeout(() => { reject(new Error('default provider request not received in 10s')) }, 10_000).unref() }), ]) - const workspaceMessage = captured.messages?.find(message => + const workspaceMessage = capturedDefault.messages?.find(message => message.role === 'user' && message.content?.includes('web-workspace-context-probe')) - const systemMessage = captured.messages?.find(message => message.role === 'system') - expect(systemMessage?.content).toContain('Stay in plan mode until exit_plan_mode succeeds') - expect(systemMessage?.content).toContain('Do not edit or write files') + const defaultSystem = capturedDefault.messages?.find(message => message.role === 'system') + expect(defaultSystem?.content).toContain('You are in default mode, not plan mode.') + expect(defaultSystem?.content).toContain('Do not call exit_plan_mode in default mode.') + expect(defaultSystem?.content).not.toContain('Stay in plan mode until exit_plan_mode succeeds') + expect(await rpc(baseUrl, 'session.setPlanMode', { + sessionId: created.sessionId, active: true, + })).toMatchObject({ pending: true }) + await rpc<{ accepted: true }>(baseUrl, 'session.prompt', { + sessionId: created.sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'plan request' }], + planMode: true, + }) + const capturedPlan = await Promise.race([ + planRequest, + new Promise((_resolve, reject) => { + setTimeout(() => { reject(new Error('plan provider request not received in 10s')) }, 10_000).unref() + }), + ]) + const planSystem = capturedPlan.messages?.find(message => message.role === 'system') + expect(planSystem?.content).toContain('Stay in plan mode until exit_plan_mode succeeds') + expect(planSystem?.content).toContain('Do not edit or write files') + expect(planSystem?.content).not.toContain('You are in default mode, not plan mode.') expect(await rpc(baseUrl, 'session.planMode', { sessionId: created.sessionId })) .toEqual({ active: true }) expect(workspaceMessage).toMatchInlineSnapshot(` diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 05a50668db..36400a7f91 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -12,6 +12,8 @@ Per-session UI state (selection, composer draft, active view) lives in the decla The default composer's bottom row exposes the session-scoped `'conversation.composer.controls'` list slot to the left of the primary action. Mode and policy features contribute controls through that slot; whole-composer takeovers such as questions remain selector-routed entries of the separate `'conversation.composer'` chain. Pending questions render only through that takeover and are omitted from chat-flow placeholders, while approvals remain visible until their own Web response surface exists. +Prompt submission has a short local admission phase distinct from model generation. The composer clears the draft, prevents a duplicate send, and waits while the session settles the latest mode selection and the Host accepts the prompt. Admission success releases that lock immediately; the independently streamed running state then keeps Stop available for the model turn. Admission failure restores the submitted draft only when the user has not supplied replacement text and surfaces through the ordinary prompt-error strip. + `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). ## Model Experience diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 78aa6043c8..1cd225d8eb 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -89,7 +89,7 @@ export function apply(ctx: Context): void { subscribe: fn => slots.subscribe('conversation.view', fn), version: () => slots.getVersion('conversation.view'), }, - send: (text, mode) => { + send: async (text, mode) => { const trimmed = text.trim() if (trimmed === '') return // Optimistic clear with failure restore (choreography lives with the @@ -97,7 +97,12 @@ export function apply(ctx: Context): void { // The store write path stays inside the declared actions set: // restoreDraft itself no-ops once the user typed something new. actions.clearDraft() - void scoped.send(trimmed, mode).catch(() => { actions.restoreDraft(trimmed) }) + try { + await scoped.send(trimmed, mode) + } catch (error: unknown) { + actions.restoreDraft(trimmed) + throw error + } }, stop: () => { scoped.cancel().catch(() => { diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1b626626e3..c3483d0ee6 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -112,8 +112,8 @@ export interface ConversationInjected { subscribe(fn: () => void): () => void version(): number } - /** Send choreography: trims, clears the draft optimistically, restores it on failure. */ - send(text: string, mode: 'queue' | 'steer'): void + /** Send choreography through Host admission: trims, clears the draft optimistically, restores it on failure. */ + send(text: string, mode: 'queue' | 'steer'): Promise /** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */ stop(): void /** Navigate to another session (breadcrumb ancestors). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 0b35f29467..c4ded82a5d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -8,7 +8,7 @@ // Breadcrumbs derive from useSessions with a pure parentId walk; the active // view id lives in the chat store's `view` field (per-session by store scope). -import { useSyncExternalStore } from 'react' +import { useEffect, useRef, useState, useSyncExternalStore } from 'react' import clsx from 'clsx' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client' @@ -54,11 +54,28 @@ export function ConversationRoot({ const promptError = useSession(s => s.promptError) const turns = useSession(s => countTurns(s)) const pending = useSession(s => s.pending) + const [submitting, setSubmitting] = useState(false) + const submittingRef = useRef(false) + const aliveRef = useRef(true) + + useEffect(() => () => { + aliveRef.current = false + }, []) const error: InputBarError | null = promptError === null ? null : { op: promptError.op, message: `${promptError.error.message}(${promptError.error.code})` } const controls = renderSlot('conversation.composer.controls', {}) + const submit = (mode: 'queue' | 'steer'): void => { + if (submittingRef.current) return + submittingRef.current = true + setSubmitting(true) + const settle = (): void => { + submittingRef.current = false + if (aliveRef.current) setSubmitting(false) + } + void send(draft, mode).then(settle, settle) + } // The default composer doubles as the chain's all-decline fallback: a // pending wait with no registered takeover must still leave the input usable. @@ -66,12 +83,13 @@ export function ConversationRoot({ { send(draft, mode) }} + onSend={submit} onStop={stop} /> ) diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx index 420ff7a622..4bb4acbb90 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx @@ -103,7 +103,8 @@ export function EmptyState({ useSessions, startSession }: EmptyStateProps) { (null) @@ -52,7 +54,7 @@ export function InputBar({ // Locked while running: the browser drops keystrokes AND focus on a disabled // textarea — no sending mid-turn, stop or wait. - const locked = disabled || running + const locked = disabled || running || submitting // Unlock (mount / session switch / turn end) returns focus to the box. useEffect(() => { @@ -80,14 +82,14 @@ export function InputBar({ inputRef.current?.focus() } - const primaryLabel = running ? '停止' : '发送' + const primaryLabel = running ? '停止' : submitting ? '发送中' : '发送' const onPrimary = (): void => { if (running) { onStop() return } /* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */ - if (!empty && !disabled) onSend('queue') + if (!empty && !disabled && !submitting) onSend('queue') } return ( @@ -108,7 +110,13 @@ export function InputBar({ className={css.input} value={draft} disabled={locked} - placeholder={placeholder ?? (disabled ? '会话不可用' : running ? '回复生成中,可停止后再输入' : '输入消息,Enter 发送,Shift+Enter 换行')} + placeholder={placeholder ?? (disabled + ? '会话不可用' + : running + ? '回复生成中,可停止后再输入' + : submitting + ? '正在发送…' + : '输入消息,Enter 发送,Shift+Enter 换行')} rows={2} onChange={(e) => onDraftChange(e.target.value)} onKeyDown={onKeyDown} @@ -123,8 +131,8 @@ export function InputBar({ type="button" className={clsx(css.primary, running && css.stopping)} aria-label={primaryLabel} - title={running ? '停止本轮' : '发送(Enter)'} - disabled={!running && (empty || disabled)} + title={running ? '停止本轮' : submitting ? '正在等待发送确认' : '发送(Enter)'} + disabled={!running && (empty || disabled || submitting)} onMouseDown={keepFocus} onClick={onPrimary} > diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 3043c71ed5..a3e3bd345d 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -147,27 +147,24 @@ describe('conversation slot inject surface', () => { const { instance, injected } = b.conversationSurface(ROOT) // Whitespace-only: no send, and the (whitespace) draft is not cleared. instance.actions.setDraft(' ') - injected.send(' ', 'queue') + await injected.send(' ', 'queue') expect(b.sessionFake.prompt).not.toHaveBeenCalled() expect(instance.store.getSnapshot().draft).toBe(' ') // Success: cleared and stays cleared. instance.actions.setDraft('hello') - injected.send('hello', 'queue') + await injected.send('hello', 'queue') expect(instance.store.getSnapshot().draft).toBe('') - await Promise.resolve() expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue') // Failure: restored (draft still empty when the rejection lands). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) instance.actions.setDraft('retry me') - injected.send('retry me', 'queue') - await vi.waitFor(() => { - expect(instance.store.getSnapshot().draft).toBe('retry me') - }) + await expect(injected.send('retry me', 'queue')).rejects.toThrow(/agent-busy: b/) + expect(instance.store.getSnapshot().draft).toBe('retry me') // Failure landing after new typing: no clobber (restoreDraft fills empty only). b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b' } }) - injected.send('retry me', 'queue') + const failed = injected.send('retry me', 'queue').catch(() => {}) instance.actions.setDraft('typed during flight') - await new Promise(r => setTimeout(r, 0)) + await failed expect(instance.store.getSnapshot().draft).toBe('typed during flight') // Stop failure is swallowed (promptError owns the surface). b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x' } }) diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index d8f75e6d8e..1eedce30ec 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -12,7 +12,7 @@ afterEach(cleanup) function setup(over?: Partial) { const props: InputBarProps = { - draft: 'hello', running: false, disabled: false, error: null, + draft: 'hello', running: false, submitting: false, disabled: false, error: null, variant: 'composer', onDraftChange: vi.fn(), onSend: vi.fn(), onStop: vi.fn(), ...over, @@ -83,6 +83,15 @@ describe('running lock and primary button', () => { expect(props.onSend).not.toHaveBeenCalled() }) + it('submission locks duplicate sends only until Host admission settles', () => { + const { textarea, button, props } = setup({ submitting: true }) + expect(textarea.disabled).toBe(true) + expect(textarea.placeholder).toBe('正在发送…') + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(props.onSend).not.toHaveBeenCalled() + }) + it('idle primary sends and disables on empty draft', () => { const { button, props } = setup() fireEvent.click(button) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 557d7df034..ccf59720a1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -8,7 +8,7 @@ * share is a REAL createChatStore().create() instance (same construction path * as production), injected callbacks are spies. */ -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -106,14 +106,14 @@ describe('ConversationRoot', () => { tabs: ViewTab[], activeView?: string, init: Partial = {}, renderSlotChain?: ConversationRootProps['renderSlotChain'], ) { - const { useSession } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) + const { useSession, store: session } = fakeSession({ nodes: [{ kind: 'user' }, { kind: 'user' }], ...init }) const { useSessions } = fakeSessions([ { id: 'root', title: 'proj' }, { id: 's1', title: 'child', parentId: 'root' }, ]) const chat = createChatStore().create() if (activeView !== undefined) chat.actions.setView(activeView) - const send = vi.fn() + const send = vi.fn(() => Promise.resolve()) const stop = vi.fn() const open = vi.fn() // The renderSlot share as the outlet would bake it: renders a marker for @@ -141,7 +141,7 @@ describe('ConversationRoot', () => { stop={stop} open={open} />) - return { ui, chat, send, stop, open, renderSlot } + return { ui, chat, session, send, stop, open, renderSlot } } const tab = (id: string, label: string): ViewTab => ({ id, label }) @@ -189,6 +189,37 @@ describe('ConversationRoot', () => { expect(send).toHaveBeenCalledWith('hi', 'queue') }) + it('locks duplicate sends only while prompt admission is unresolved', async () => { + const { session, send, stop } = bench([tab('chat', 'Chat')]) + let resolve!: () => void + send.mockImplementationOnce(() => new Promise((done) => { resolve = done })) + const box = screen.getByPlaceholderText(/输入消息/) + fireEvent.change(box, { target: { value: 'wait for mode' } }) + fireEvent.keyDown(box, { key: 'Enter' }) + + expect((screen.getByRole('button', { name: '发送中' }) as HTMLButtonElement).disabled).toBe(true) + expect((box as HTMLTextAreaElement).disabled).toBe(true) + fireEvent.keyDown(box, { key: 'Enter' }) + expect(send).toHaveBeenCalledTimes(1) + + session.set({ ...session.getSnapshot(), running: true }) + const stopButton = await screen.findByRole('button', { name: '停止' }) as HTMLButtonElement + expect(stopButton.disabled).toBe(false) + fireEvent.click(stopButton) + expect(stop).toHaveBeenCalledTimes(1) + + resolve() + await waitFor(() => { + expect(screen.getByRole('button', { name: '停止' })).toBeTruthy() + }) + expect((box as HTMLTextAreaElement).disabled).toBe(true) + session.set({ ...session.getSnapshot(), running: false }) + await waitFor(() => { + expect((screen.getByRole('button', { name: '发送' }) as HTMLButtonElement).disabled).toBe(false) + }) + expect((box as HTMLTextAreaElement).disabled).toBe(false) + }) + it('dispatches the pending list to the composer chain; all-decline falls back to InputBar', () => { const wait = new PendingWait('question', RpcId('rq'), sid('s1'), { questions: [{ id: 'mode', question: 'Choose?', options: [{ label: 'Fast' }] }] } as PendingWait<'question'>['payload'], vi.fn()) diff --git a/packages/client/ui-plan/README.md b/packages/client/ui-plan/README.md index 2a2a4478cd..a82648b86d 100644 --- a/packages/client/ui-plan/README.md +++ b/packages/client/ui-plan/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-client-ui-plan -Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product policy; the browser entry contributes a session-scoped selector to `conversation.composer.controls`. +Web plan-mode feature with two lifecycle-coupled halves. The node entry mounts `@deepseek-ai/dsh-plan-mode` with the Web product's active and default mode policies; the browser entry contributes a session-scoped selector to `conversation.composer.controls`. The selector distinguishes unavailable capability (`planMode === null`), committed mode (`active`), and the target queued for the next model-request boundary (`pending`, including `pending: false`). Selecting a mode never cancels a running turn. It remains available while generation is running, disables only during its own RPC, and displays the host-confirmed pending target until a logged `plan/mode` event commits it. The transparent native select mirrors keyboard focus onto the visible chip and carries a dynamic accessible description of the committed and pending modes. @@ -8,11 +8,11 @@ The model exits plan mode through the stable `exit_plan_mode` tool. Its plan rev ## Model Experience -Indirectly, through `@deepseek-ai/dsh-plan-mode`; that package owns policy activation, the exit-tool schema and rendering, logged state, and request-boundary transitions, while this package supplies the Web composition's section text. +Indirectly, through complementary Web system-prompt sections: active mode supplies the planning policy, while default mode explicitly states that the session is not planning, permits normal implementation work, and tells the model not to call the still-registered `exit_plan_mode` tool; `@deepseek-ai/dsh-plan-mode` continues to own the exit-tool schema and rendering, logged state, and request-boundary transitions. #### KV Cache effect -Entering or leaving plan mode changes the active system-prompt section and therefore the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition. +Entering or leaving plan mode swaps the active Web mode section and therefore changes the request prefix. The stable exit-tool registration avoids an additional tool-catalog shape change across the same transition. ## Known Limitations and Deferred Work diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 709e88677d..81ab46c604 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -45,9 +45,11 @@ "cordis": "^4.0.0-rc.7" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/client/ui-plan/src/index.ts b/packages/client/ui-plan/src/index.ts index 7face1a04d..214376be64 100644 --- a/packages/client/ui-plan/src/index.ts +++ b/packages/client/ui-plan/src/index.ts @@ -3,7 +3,7 @@ * logged plan-mode service with the Web product's planning policy. */ import type { Context } from 'cordis' -import PlanModeService from '@deepseek-ai/dsh-plan-mode' +import PlanModeService, { foldPlanMode } from '@deepseek-ai/dsh-plan-mode' /** Host services required by plan mode. */ export const inject = ['tools', 'systemPrompt'] @@ -21,10 +21,20 @@ Make the plan decision-complete: state the goal and success criteria; group impl When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation.` +/** Web product-owned guidance rendered while plan mode is inactive. */ +export const WEB_DEFAULT_SECTION = 'You are in default mode, not plan mode. Follow the user\'s request normally, including implementing changes when requested. Do not call exit_plan_mode in default mode. It remains in the tool catalog only for request-cache stability and becomes valid only after the user switches this session to plan mode. This current mode statement overrides earlier conversational text that described the session as being in plan mode.' + /** * Mount plan mode for hosts that selected the Web plan plugin. * @param ctx - Host context carrying tools and systemPrompt. */ export function apply(ctx: Context): void { + ctx.systemPrompt.section({ + name: 'plan:default-policy', + order: 50, + text: context => context.agent !== undefined && !foldPlanMode(context.agent.session.events) + ? WEB_DEFAULT_SECTION + : '', + }) ctx.plugin(PlanModeService, { section: WEB_PLAN_SECTION }) } diff --git a/packages/client/ui-plan/tests/node-plugin.spec.ts b/packages/client/ui-plan/tests/node-plugin.spec.ts index 3669d61195..434f4df7fb 100644 --- a/packages/client/ui-plan/tests/node-plugin.spec.ts +++ b/packages/client/ui-plan/tests/node-plugin.spec.ts @@ -4,7 +4,9 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { EXIT_PLAN_MODE } from '@deepseek-ai/dsh-plan-mode' -import { WEB_PLAN_SECTION, apply, inject } from '../src/index.ts' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { WEB_DEFAULT_SECTION, WEB_PLAN_SECTION, apply, inject } from '../src/index.ts' let ctx: Context | undefined @@ -27,13 +29,45 @@ describe('ui-plan node plugin', () => { expect(WEB_PLAN_SECTION).toContain('Stay in plan mode until exit_plan_mode succeeds') expect(WEB_PLAN_SECTION).toContain('Do not edit or write files') expect(WEB_PLAN_SECTION).toContain('Make exit_plan_mode the only and final tool call') + expect(WEB_DEFAULT_SECTION).toContain('default mode, not plan mode') + expect(WEB_DEFAULT_SECTION).toContain('Do not call exit_plan_mode in default mode') expect((await ctx.systemPrompt.assemble()).sections) - .toEqual(expect.arrayContaining([expect.objectContaining({ name: 'plan:policy', text: '' })])) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'plan:default-policy', text: '' }), + expect.objectContaining({ name: 'plan:policy', text: '' }), + ])) await feature.dispose() expect(ctx.get('planMode')).toBeUndefined() expect(ctx.tools.get(EXIT_PLAN_MODE)).toBeUndefined() expect((await ctx.systemPrompt.assemble()).sections.map(section => section.name)) - .not.toContain('plan:policy') + .toEqual(expect.not.arrayContaining(['plan:default-policy', 'plan:policy'])) + }) + + it('states the exact Web collaboration mode at every agent assembly', async () => { + ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(UserInteractionService) + const feature = ctx.plugin({ inject: [...inject], apply }) + await feature.await() + + const events: SessionEvent[] = [] + const agent = { session: { events } } as unknown as Agent + let sections = (await ctx.systemPrompt.assemble({ agent })).sections + expect(sections.find(section => section.name === 'plan:default-policy')?.text) + .toBe(WEB_DEFAULT_SECTION) + expect(sections.find(section => section.name === 'plan:policy')?.text).toBe('') + + events.push({ + type: 'plan/mode', + seq: 0, + time: 1, + data: { active: true }, + }) + sections = (await ctx.systemPrompt.assemble({ agent })).sections + expect(sections.find(section => section.name === 'plan:default-policy')?.text).toBe('') + expect(sections.find(section => section.name === 'plan:policy')?.text) + .toBe(WEB_PLAN_SECTION) }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2f0e61a225..2d568f40fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -686,6 +686,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -695,6 +698,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt