diff --git a/.github/workflows/ci-gui-daily.yml b/.github/workflows/ci-gui-daily.yml index 73556f478e..282acf7512 100644 --- a/.github/workflows/ci-gui-daily.yml +++ b/.github/workflows/ci-gui-daily.yml @@ -38,11 +38,12 @@ jobs: nuget-${{ runner.os }}- - name: Build - run: dotnet build native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj -c Release --nologo + run: dotnet build native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj -c Release --nologo - name: Test run: > - dotnet test native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj + dotnet test native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj + dotnet test native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj -c Release --no-build --nologo --filter "Category!=Integration&Category!=Benchmark" --logger "console;verbosity=detailed" @@ -53,5 +54,5 @@ jobs: uses: actions/upload-artifact@v7 with: name: gui-test-results - path: '**/JoinCodeGui.trx' + path: '**/NativeGui.*.Tests.trx' retention-days: 7 diff --git a/.github/workflows/ci-unit-tests.yml b/.github/workflows/ci-unit-tests.yml index b9b51e2b36..99c4820831 100644 --- a/.github/workflows/ci-unit-tests.yml +++ b/.github/workflows/ci-unit-tests.yml @@ -48,10 +48,14 @@ jobs: - domain: apps # Host.Tests(CLI 侧)跨平台,ubuntu 跑 csprojs: test/unit/host.tests/Host.Tests.csproj - - domain: apps-gui - # GUI 测试:CsWinRT 投影工具链(Windows PE)+ WebView2 仅 Windows 原生可跑,双平台拆分 + - domain: gui-core + # GUI 协议核心(ADR 0145 B3):纯 net10.0 零 Avalonia/WebView2 — ubuntu 可跑,windows job 收窄 + os: ubuntu-latest + csprojs: native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj + - domain: gui-host + # GUI 宿主表现层:CsWinRT 投影工具链(Windows PE)+ WebView2 仅 Windows 原生可跑 os: windows-latest - csprojs: native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj + csprojs: native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj steps: - name: Checkout diff --git a/build/sln/Gui.slnx b/build/sln/Gui.slnx index 34219eec64..b9074e8c95 100644 --- a/build/sln/Gui.slnx +++ b/build/sln/Gui.slnx @@ -5,7 +5,10 @@ - - + + + + + diff --git a/docs/proposed/adr/0146-steer-in-turn-injection-mailbox.md b/docs/proposed/adr/0146-steer-in-turn-injection-mailbox.md new file mode 100644 index 0000000000..f0d740f958 --- /dev/null +++ b/docs/proposed/adr/0146-steer-in-turn-injection-mailbox.md @@ -0,0 +1,58 @@ +# ADR 0146: 轮内注入(steer)— 步首邮箱 drain + 引擎事件投影 + +- 状态:accepted +- 日期:2026-10-22 +- 决策者:liuqihong(TASK060 B11:宿主 `session/prompt mode:"steer"` 目前诚实拒绝 `session/steer-unavailable`,需补齐 DSH 轮内注入) + +## 背景 + +### DSH 参考语义(权威源) + +| 位置 | 事实 | +|------|------| +| `dsh-agent-loop/lib/index.js:800-814` | `send(message,target,wakeup)`;`followup→next-turn`、`steer→next-step(true)`、`inject→next-step(false)`;**abort 后若 wakeup 则回落到 `next-turn`** | +| `dsh-agent-loop/lib/index.js:998-1006` | 模型本步无工具调用(`turnEnds` 已定)但 `inbox.nextStep.length > 0` → **不结束轮次**,`target="next-step"` 继续下一步;同时跳过 `agent/turn-stopping` | +| `dsh-agent-loop/lib/index.js:902-925` | `preStep` 用 `inbox.claim(target, turn)` 取出待注入消息 → `agent/pre-step` waterfall → 作为该步输入 | +| `dsh-agent-loop/lib/index.js:1061` | 步首(`firstAttempt`)把取出消息逐条 `session.append("user/message", message, { surfaceOp: "append" })` → **注入消息在轮内、步首落事件** | +| `dsh-api-session-controller/lib/index.js:882` | `request.mode === "steer" ? agent.steer(message) : agent.followup(message)`,受理即回 `{accepted:true}` | +| `dsh-api-session-controller/lib/index.js:966` | 仅当把「已排队项」升级为 steer(`action.kind==='steer'`)且目标不是 `next-turn` / agent 未 running 时才报 `session/steer-unavailable` | +| `dsh-api-session-controller/lib/client.js:1686` | 前端放置:`running ? (mode==='steer' ? 'steering' : 'queued') : 'transcript'` | +| `dsh-client-ui-conversation/lib/index.js:9` | 忙碌时回车行为可选 `["queue","steer"]`(用户设置),即 **steer 是常规可用路径** | + +### 本仓现状 + +- 宿主 `DshRpcDispatcher` 对 `session/prompt mode:"steer"` 与 `session/updateQueue action.kind:"steer"` 直接返回 `session/steer-unavailable`(诚实拒绝,无注入点)。 +- 引擎 `ChatService.StreamWithEventsAsync` → `QueryLoopMiddleware`(`modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs`)是工具循环:`while (totalToolCalls < 128)`,每次迭代 = 一次 LLM 请求(step)。 +- 每步开头已有「可 drain 的注入」模板:`_notificationHandler.ProcessPendingNotificationsAsync(ct)`(`:125`)→ `_contextManager.AddUserMessageAsync(...)`(`BackgroundNotificationHandler.cs:39-53`);步首 `_contextManager.GetMessageListAsync`(`:127`)即取到新注入消息。 +- 结束条件(`:157-160`):本步无工具调用且有正文 → `break`(**没有 steer 检查**,即模型最后一步会直接收尾)。 +- `ChatMiddlewareContext.SessionId` 可用(`:84`),管道/`IChatContextManager` 均按会话隔离;子代理通道 `SubAgentEventChannel`(`:116`)证明「轮内通道挂 context 显式传递」是本仓既有模式(异步迭代器内禁用 AsyncLocal)。 +- 宿主 `IJccChatSession`(`native_gui/.../hosting/IJccChatSession.cs`)只有 `StreamAsync`,无主会话注入方法。 + +## 决策 + +**新增按会话寻址的 steer 邮箱(Actor 邮箱模型,消息传递替代共享锁):宿主受理 `mode:"steer"` 时投递;引擎在每步开头 drain 并作为该步输入落为轮内 `user/message` 事件。** + +1. **抽象**:`ISteerInputMailbox`(`modules/abstractions/...`):`Enqueue(sessionId, text)` / `HasPending(sessionId)` / `DrainAll(sessionId)`(FIFO,线程安全,无锁竞争——`ConcurrentDictionary>`,空队列即移除键)。 +2. **引擎消费点**:`QueryLoopMiddleware` 步首(紧邻 `ProcessPendingNotificationsAsync`)drain 本会话邮箱 → 逐条 `_contextManager.AddUserMessageAsync(text, MessageOriginKind.SteerInput, ct)`,并 `yield return` 新事件 `ChatStreamEventType.SteerInput`(携带文本)供宿主投影。 +3. **轮次延续**:`:157` 的收尾分支加「`HasPending` 则 `continue`」——对齐 DSH `next-step 非空不结束轮次`。 +4. **枚举扩展**:`MessageOriginKind` 增 `[EnumValue("steer")] SteerInput`(唯一数据源,源码生成消费);`ChatStreamEventType` 增 `[EnumValue("steerInput")] SteerInput`。 +5. **宿主路由**: + - `session/prompt mode:"steer"`:轮次运行中 → 投递邮箱并返回 `{accepted:true}`(**不再** `steer-unavailable`);**无运行轮次 → 退回正常开轮**(等价 DSH 空闲时 steer 会唤醒一次 turn)。 + - `session/updateQueue action.kind:"steer"`(把排队项升级为轮内注入):从队列移除 + 投递邮箱;未运行或队列项不存在仍按 DSH 语义报错(`session/steer-unavailable` / `session/queue-item-not-found`)。 + - 宿主观察引擎 `SteerInput` 事件 → 落 `user/message`(`surfaceOp:"append"`,新 `Id`,`Source.Kind="user"`),位置即步首,**仍在当前 turn 内**。 +6. **契约扩展**:`IJccChatSession` 增注入方法(如 `bool TrySteerInput(string sessionId, string text)`),实现从会话服务解析邮箱;不改变 `StreamAsync` 签名(一轮独占语义保留)。 + +## 替代方案(考虑过但放弃) + +- **宿主侧只入队、由宿主自己发 `user/message`**:事件位置在「收到 steer 那一刻」,可能落在某步中途(工具执行中),与 DSH「步首 append」不符,且无法让 LLM 在下一步看到(引擎不消费队列就永远看不到)。 +- **`IChatMiddleware` 中间件实现**:中间件包裹整个 QueryLoop,只能在轮首/轮尾各跑一次,插不到步循环内部(实测包裹边界)。 +- **改 `IChatService.StreamAsync` 签名传 Channel**:把轮内注入变成流调用参数,破坏「一轮一次调用」的现有抽象,且 CLI/TUI/子代理所有消费方都要改。 +- **复用 `IAgentNotificationQueue`**:那是全局单例、内容被包成任务通知 XML 且 `MessageOriginKind.TaskNotification`,无法表达「人类 steer 输入」与按会话寻址。 +- **`AgentInputForwardQueue`(子代理输入转发的 `Channel`)**:语义是转发给**子代理**且只在回合构建阶段 drain(`AgentBase.cs:514`),主会话不可用。 + +## 影响面 + +- 新增:`ISteerInputMailbox` 抽象 + 实现 + DI 注册;`MessageOriginKind.SteerInput`;`ChatStreamEventType.SteerInput`。 +- 修改:`QueryLoopMiddleware`(步首 drain + 结束条件)、`DshTurnRunner`/`DshRpcDispatcher`(steer 路由)、`JccChatSession`(注入方法)、宿主 `SteerInput` 事件投影。 +- 测试:引擎单测(步首注入进下一次请求 / 有注入时不收尾);宿主单测(运行中 steer 受理 + 未运行回落 / updateQueue 升级);E2E `dsh-webui-steer.mjs`(桩服务器按注入文本应答,断言轮内 `user/message` + 单轮 + 下步请求含注入文本)。 +- 与 `assistant-stream`(逐字流式)无关,两者独立,可分别落地。 diff --git a/docs/proposed/adr/0146-steer-in-turn-injection.md b/docs/proposed/adr/0146-steer-in-turn-injection.md new file mode 100644 index 0000000000..6200fc0b2c --- /dev/null +++ b/docs/proposed/adr/0146-steer-in-turn-injection.md @@ -0,0 +1,44 @@ +# 0146. steer 轮内注入(引擎架构扩展) + +- 状态:proposed +- 日期:2026-10-22 +- 决策者:项目架构组 + +## 背景 + +DSH 前端两个消费点依赖 steer(轮内注入): +1. `session/prompt mode:"steer"` — 忙时发送直接注入当前轮 +2. `session/updateQueue action.kind:"steer"` — QueueDock 把排队消息逐条 steer 注入 + +引擎现状(2026-10-21 调研实证):`IJccChatSession.StreamAsync(string, ct)` 一轮独占 `IAsyncEnumerable`,**无运行中注入点**;`modules/apps/engine/` 全目录无 `Steer` 命中(`PromptInjection` 是轮前 `!` 命令注入,非轮内)。宿主侧 updateQueue 的 steer 分支当前诚实返回 `session/steer-unavailable`。 + +已有可参考资产:w2 侧 `InterruptAndSend → RequestSteer` 子代理邮箱转发链(路由测试 6/6 绿)——证明 Actor 邮箱模型支持"打断+发送"语义,但那是 AgentService 层,非 ChatService 轮次层。 + +## 决策(草案) + +在 ChatStream 中间件管道插入**注入中间件**(`SteerInjectionMiddleware`): + +1. 引擎暴露 `ISteerChannel`(Channel)——宿主 `session/prompt mode:"steer"` 在忙轮写入 +2. 注入点选**工具调用间隙**(ToolCallEnd → ToolCallStart 之间):中间件在流转发间隙 `TryRead` 非阻塞探针,命中即把 SteerMessage 追加为 user 消息进下一 LLM 请求 +3. 注入结果以 `ChatStreamEvent` 新枚举 `Steered` 上报(宿主投影成 DSH `session/steer-accepted` 事件) +4. 队列 drain:轮次结束(Complete)后由 TurnRunner 检查 pending steer 队列(与现有 mode=queue 接续复用) + +## 替代方案(考虑过但放弃) + +| 方案 | 放弃原因 | +|------|----------| +| 降级"下轮优先"(steer 语义换 queue) | 与前端 steer 提示(注入当前轮)不符,欺骗性兼容 | +| StreamAsync 加第二参数/新接口 SteerAsync | 破坏 IAsyncEnumerable 单流模型,所有宿主改动面大 | +| AgentService 层 InterruptAndSend 复用 | 层级错配:那是子代理邮箱打断,非主会话轮内注入 | + +## 开放问题(实施前须拍板) + +1. 注入窗口边界:纯文本回复中段是否允许注入(当前草案只在工具间隙)? +2. sensenova 等 openai-compatible 供应商对"请求已发出后追加上下文"的支持边界(服务端不可注入,只能本地循环新请求) +3. Token 预算:注入消息计入当前轮预算还是下一轮 + +## 关联 + +- 上游:ADR 0143(DSH 托管)、TASK058 P2-B3(updateQueue steer 诚实拒绝) +- 参照:w2 InterruptAndSend→RequestSteer 链(AgentService 层先例) +- 消费:DSH QueueDock steerQueue()、session/prompt mode:"steer" diff --git a/docs/proposed/adr/0147-assistant-stream-transient-frames.md b/docs/proposed/adr/0147-assistant-stream-transient-frames.md new file mode 100644 index 0000000000..28bf86b628 --- /dev/null +++ b/docs/proposed/adr/0147-assistant-stream-transient-frames.md @@ -0,0 +1,56 @@ +# ADR 0147: assistant-stream 逐字流式 — 宿主合成 DSH 瞬态帧 + +- 状态:proposed +- 日期:2026-10-22 +- 决策者:liuqihong(TASK060 B13:`session/follow` 的 `assistantStream` 恒 `revision=0`,界面上助手回复只在整段完成后才出现,与 DSH 的逐字流式不一致) + +## 背景 + +### DSH 参考语义(权威源) + +| 位置 | 事实 | +|------|------| +| `dsh-api-session-controller/lib/index.js:1451-1545` | `follow` 收到 `assistantStream:true` 时:快照必须带 `assistantStream` 基线;随后每条帧以 `{type:'assistant-stream',frame}` 推送;帧是**瞬态**(不进会话日志、不占 seq) | +| `dsh-api-session-controller/lib/types/client/transport.js:78-108` | 客户端**强校验**:快照缺 `assistantStream` → 直接抛 `gateway/internal`;每帧 `revision` 必须等于 `上一帧+1`,否则抛 `session assistant stream skipped revision` 并断开流 | +| `dsh-api-session-controller/lib/typert.host.js:2084-2089` | `SessionAssistantStreamBaseline {revision, activeAttempt?}`;`SessionAssistantStreamFrame = start{attemptId,revision,startedAfterSeq,turn,step} \| chunk{attemptId,revision,index,time,chunk} \| end{attemptId,revision,index,outcome{committed{eventType,seq}\|abandoned}}` | +| `dsh-agent-loop/lib/index.js:1050-1110` | 每次 LLM 尝试:`live.start()` → 逐 chunk `push` → 落盘 `assistant/message`/`assistant/attempt` 后 `settle()` 发 `end{committed,seq}`;失败/取消 `abandon()` | +| `dsh-llm/lib/types/assembler.js:8-20` | 组装器「容忍纯 delta 协议(无 block-start/end)」,`text-delta`/`reasoning-delta` 即可增量成块 | +| `dsh-client-ui-chat/client.js`(`updateChunk`) | 前端把 `assistant/live-chunk` 折成 `status:"running"` 的助手节点:`text-delta{index,text}`、`reasoning-delta{index,text}` 直接追加到该 index 的块 | +| `dsh-llm/lib/types/assistant-stream.js:validateRecord` | 基线里的紧凑流记录必须是 `{type:'chunk',time,chunk}`(精确键)或打包记录;重连时客户端 `expandAssistantStream` 逐条展开 | + +### 本仓现状 + +- `DshSessionFollowSnapshot.AssistantStream` 存在但恒为 `{revision:0}`(无 `activeAttempt`),前端因此只渲染「正在生成」而无正文增量。 +- `DshSessionStreamHub.PublishAsync` 只支持**持久**事件(分配 seq + 落 journal);没有瞬态帧通道。 +- `DshTurnRunner.RunTurnAsync` 已按 delta 收到引擎的 `Content`/`Thinking` 事件(`:129-133`),但只累加,整轮结束才落一条 `assistant/message`(`:150`)。 +- 引擎的 step 粒度(一次 LLM 请求 = 一个 step)目前被宿主压平:所有工具事件与最终回复都写 `Step=1`,一条 `assistant/message`。 + +## 决策 + +**宿主在 `DshSessionStreamHub` 内维护每会话的 assistant-stream 状态机,由 `DshTurnRunner` 把引擎 delta 翻译成 DSH 瞬态帧。** + +1. **帧合成**(`DshTurnRunner`): + - 本轮首个 `Content`/`Thinking` delta → `start`(`startedAfterSeq` = 当时最后一个持久 seq); + - `Thinking` delta → `chunk{index:0, chunk:{type:'reasoning-delta',index:0,text}}`;`Content` delta → `chunk{index:1, chunk:{type:'text-delta',index:1,text}}`(与最终 `assistant/message` 的「reasoning 在前、text 在后」块序一致); + - `assistant/message` 落盘后 → `end{outcome:{kind:'committed',eventType:'assistant/message',seq:<该事件 seq>}}`; + - 取消 → `end{abandoned}`;异常 → `end{abandoned}`(不伪造 committed)。 +2. **状态机**(hub):每会话 `revision` 单调递增(**永不重置**,跨尝试、跨重连),`activeAttempt{attemptId,startedAfterSeq,turn,step,nextIndex,stream[]}` 只存原始 `{type:'chunk',time,chunk}` 记录(不打包,正确优先于体积)。 +3. **快照基线**:`session/follow` 打开时把当前 `{revision, activeAttempt?}` 放进快照的 `assistantStream`;订阅者从 `revision+1` 起的帧继续收(满足客户端密度校验,重连能重建未完成文本)。 +4. **传输**:帧以 `{type:'assistant-stream',frame}` 经同一 WebSocket 发;**不落 journal、不分配 seq**(瞬态语义,重放以 `assistant/message` 为准)。 +5. **粒度**:一次「一轮」(turn)一个 attempt。引擎的 step 折叠属于既有投影约定,流式帧随该约定(后续若要 step 级流式,改为按 `Complete` 事件切 attempt,协议不变)。 + +## 备选方案 + +| 方案 | 不采用原因 | +|------|-----------| +| 让引擎直接产出 DSH chunk 协议 | 引擎是自有抽象(`ChatStreamEvent`),把宿主协议钉进引擎会污染领域层,且引擎 AOT 序列化与 DSH zod 语义不一致 | +| 前端只显示「正在生成」不显示增量 | 就是本次要修的缺陷;DSH 前端已内置 `assistant/live-chunk` 渲染,白给的能力不用 | +| 把帧落进会话日志(占 seq) | 违反 DSH 瞬态定义:重连基线已经用 `activeAttempt.stream` 重建,落盘只会让历史投影出现重复/半截消息 | +| 用 `block-start`/`block-end` 包裹 delta | 组装器明确容忍纯 delta,多两类帧只增加密度校验失败面,无收益 | +| 在 Web 层单独开一条流 | 前端只认 `session/follow` 的多态帧(同一 streamId),另开流前端拿不到 | + +## 后果 + +- 正向:界面逐字出字;中途刷新/重连能看到已生成的部分文本且不重复;`end.seq` 让前端把瞬态行替换成持久消息(不残影)。 +- 负向:`tool-call` 的流式参数仍未合成(工具卡片继续由持久 `tool/call` 驱动),已记录为遗留项。 +- 约束:帧 `revision` 必须严格 +1(客户端强校验,跳号直接断流);`assistant/message` 必须先落盘再发 `end`(`seq` 是替换锚点)。 diff --git a/docs/proposed/adr/0147-dsh-terminal-conpty.md b/docs/proposed/adr/0147-dsh-terminal-conpty.md new file mode 100644 index 0000000000..8414c85349 --- /dev/null +++ b/docs/proposed/adr/0147-dsh-terminal-conpty.md @@ -0,0 +1,44 @@ +# 0147. DSH 终端子系统(ConPTY 集成) + +- 状态:proposed +- 日期:2026-10-22 +- 决策者:项目架构组 + +## 背景 + +DSH 前端 sidebar-terminal 十方法(create/list/write/resize/rename/close/follow/retain/environment/shells)需要宿主提供真实 PTY。引擎现状:**无 ConPTY/PTY 任何能力**(2026-10-21 调研)。这是 P2 唯一全新子系统。 + +前端契约要点(typert 挖掘): +- `WebTerminalInfo`:id/title/shell{path,args,name}/cwd/cols/rows/state(failed|running|exited)/exitCode/controllerId +- `terminal/follow` 流:snapshot{screen,sequence} + output{data} + state 帧(xterm 类渲染) +- `terminal/retain`:后台保留流 +- shells/environment:探测可用 shell 与上限(maxInputBytes/maxCols/maxRows/scrollback) + +## 决策(草案) + +新建 `modules/perception/terminal/` 功能域(对齐 ADR 0137 就近隔离): + +1. **PTY 核心**:Windows ConPTY P/Invoke(`CreatePseudoConsole` API,AOT 安全无反射);Linux 后续 unixpty(本仓库 Windows-only GUI 消费,Linux 为深化项) +2. **TerminalService**:终端生命周期(create/close/list/rename)+ 会话注册表(`~/.jcc/dsh_terminals.json` 元数据 + 运行态内存表) +3. **IO 泵**:ConPTY 双管道(input/output)→ Channel;follow 流消费 output Channel 推帧;resize 透传 `ResizePseudoConsole` +4. **宿主桥**:NativeGui.Core 加 `DshTerminalService`(RPC 十方法 + WS 流两端点),帧投影按前端契约 +5. **安全**:shell 白名单(powershell/pwsh/cmd/git-bash 探测复用 `JCC_POWERSHELL_PATH` 先例);命令执行走 guard 权限管道(首次 create 需 approval) + +## 替代方案(考虑过但放弃) + +| 方案 | 放弃原因 | +|------|----------| +| pipe 假终端(stdin/stdout 无 PTY) | 无屏幕缓冲/resize 语义,xterm 渲染失真——调研已判"语义差距大" | +| 集成第三方库(winpty/Pty.Net) | nuget AI 包禁令外仍有 AOT 兼容风险;ConPTY P/Invoke 面小可控 | +| 塞进 NativeGui.Core | 终端是引擎域能力(CLI/TUI 亦可消费),非 GUI 私有 | + +## 开放问题(实施前须拍板) + +1. scrollback 上限与内存策略(前端期望值域未定标) +2. 多终端并发上限(每会话 N 个?) +3. 退出态 terminal 的 follow 回放(retain 语义)是否持久化 output 流 + +## 关联 + +- 上游:ADR 0143(DSH 托管)、ADR 0137(功能域就近隔离) +- 消费:DSH sidebar-terminal 面板 diff --git "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" index 4ff3843a78..46fe6b534d 100644 --- "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" +++ "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" @@ -38,10 +38,10 @@ | 12 | 文件树 workspaceFiles/list\|read\|readBytes\|stat | ✅ 真机(弹栈式防穿越+version 合成;changes 流深化项) | | 13 | 附件 fileUploads/upload + session/attachment + uploadFileBinary 直传 | ✅ 真机(魔数探测+四格式尺寸头解析;prompt file part 消费属引擎多模态深化) | | 14 | 队列 session/updateQueue(edit/remove) | ✅ 单元 4/4+真机契约(steer 诚实返回 steer-unavailable;队列可见性 inbox 投影深化) | -| 15 | 终端(10 方法) | ⬜ 需引擎大扩展(ConPTY/屏幕缓冲/多 shell),另行立项 | -| 16 | goals(7 方法+事件) | ⬜ 需引擎中扩展(clock/goal 生命周期模型映射+revision CAS 适配),另行立项 | -| 17 | steer / prompt mode:"steer" | ⬜ 需引擎大扩展(StreamAsync 轮内注入点),另行立项 | -| 18 | workspaceFiles/changes 流 | ⬜ watcher 桥接深化项 | +| 15 | 终端(10 方法) | ⬜ proposed ADR [0147](../adr/0147-dsh-terminal-conpty.md)(ConPTY 功能域+TerminalService+宿主桥;开放问题拍板后实施) | +| 16 | goals(7 方法+事件) | ✅ 2026-10-22 P2-B5:DshGoalStore 宿主数据闭环(七方法状态机+revision CAS+roundsStarted 受理递增+activation 翻转事件桥 $events waterfall 帧);引擎节点图 goal 联动(每 N 轮自动 evaluate)属深化项 | +| 17 | steer / prompt mode:"steer" | ⬜ proposed ADR [0146](../adr/0146-steer-in-turn-injection.md)(SteerInjectionMiddleware 工具间隙注入+ISteerChannel;开放问题拍板后实施) | +| 18 | workspaceFiles/changes 流 | ✅ 2026-10-22 P2-B4:CreateChangeFeedAsync(IFileSystem.Watch 统一工厂;Created/Changed→version 帧、Deleted→absent 帧)+ DshWebHost 流端点(ready 首帧+后台泵+租约清理);单元 4/4 + 真机 WS 全链(ready→change 帧含 absolutePath+version) | ### 不接 ❌(DSH 在线服务,与本项目无关) account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAnalytics、schedule、officeToPdf —— 保持 `unimplemented` 引导错误。 diff --git "a/docs/proposed/task/TASK059-native_gui\346\220\254\350\277\201\344\270\216CoreHost\345\210\206\345\261\202.md" "b/docs/proposed/task/TASK059-native_gui\346\220\254\350\277\201\344\270\216CoreHost\345\210\206\345\261\202.md" index ee5ce57ea7..3338375923 100644 --- "a/docs/proposed/task/TASK059-native_gui\346\220\254\350\277\201\344\270\216CoreHost\345\210\206\345\261\202.md" +++ "b/docs/proposed/task/TASK059-native_gui\346\220\254\350\277\201\344\270\216CoreHost\345\210\206\345\261\202.md" @@ -19,7 +19,7 @@ |------|------|------| | B1 | NativeGui.Core 建立 + 31 文件 git mv + GuiDiag + theming partial + 旧项目引 Core | ✅ 8de8686eb(编译 0 错误 + 253 绿) | | B2 | NativeGui.Host(AssemblyName=NativeGui.Host)+ 表现层 23 文件 git mv + 旧 csproj 归档 .xxx | ✅ 真机启动全链路(listening/目录 6 组/引擎 attach/前端 RPC) | -| B3 | 测试项目整体 git mv native_gui/tests + tests 层 Directory.Build.props 承接测试包注入 + OutputType=Exe | ✅ 253/253 绿(ubuntu 拆分另立优化项) | +| B3 | 测试拆分 NativeGui.Core.Tests(纯 net10.0,236 测试 ubuntu 可跑)+ NativeGui.Host.Tests(windows TFM,34 测试) | ✅ 2026-10-22 拆分落地(CI matrix gui-core=ubuntu/gui-host=windows + 分支保护 required 同步) | | B4 | Gui.slnx 三段登记/module.slnx 删 gui/CI 三 workflow 路径更新(python yaml 校验过) | ✅ CI 合入后验收 | | B5 | (可选独立 PR)命名空间统一 NativeGui.* | ⬜ | diff --git "a/docs/proposed/task/TASK060-DSH\345\211\251\344\275\231\345\257\271\351\275\220\344\270\216\351\252\214\346\224\266.md" "b/docs/proposed/task/TASK060-DSH\345\211\251\344\275\231\345\257\271\351\275\220\344\270\216\351\252\214\346\224\266.md" new file mode 100644 index 0000000000..a239ec5227 --- /dev/null +++ "b/docs/proposed/task/TASK060-DSH\345\211\251\344\275\231\345\257\271\351\275\220\344\270\216\351\252\214\346\224\266.md" @@ -0,0 +1,244 @@ +# TASK060 — DSH 剩余对齐与验收收尾 + +> 延续 [TASK058](TASK058-DSH引擎深度对齐与DTO统一.md)(B1–B8 已落地并真机验收)与 +> [ADR 0143](../adr/architecture/0143-dsh-web-frontend-hosting.md)。 +> 目标:把「已实现但验证手段过期」与「尚未实现的成块能力」分清,先恢复自动化验收能力,再逐项推进。 + +## 一、现状结论(2026-10-21 检查) + +- TASK058 B1–B8 已实现且单测绿(`native_gui/tests/join_code_gui.tests` 270 通过 / 0 失败),NativeGui.Host Debug 编译通过。 +- 目录已迁移到 `native_gui/src/NativeGui.Core/web_host/*`(ADR 0145),`modules/apps/gui/*` 旧路径不再存在。 +- **遗留不一致**:`non_deliverables_tools/dsh_parity/dumps/*.mjs` 仍按旧存储/旧路径断言: + - `dsh-webui-prompt.mjs` 断言 `sessions//transcript.json`(现为 `transcript.jsonl`,ADR 0144)。 + - `dsh-webui-history.mjs` 以 JSON 数组写 `transcript.json` 播种历史(现引擎优先读 `.jsonl`)。 + - `make-jcc-home-messages.mjs`、`make-fixture-session.mjs` 同样按旧格式写种子数据。 +- E2E 启动方式仍可用:`NativeGui.Host.exe` 默认进 DSH 前端(`--webui` 已并入默认路径,`--webui-smoke` 是遗留自研面)。 + +## 二、剩余能力盘点 + +| # | 能力 | 现状 | 处理 | +|---|------|------|------| +| 1 | E2E 脚本与新存储/路径同步 | 断言过期 | **本批 B9**(恢复回归能力) | +| 2 | 审批弹窗真机交互验收(TASK058 B1 未竟项) | `$events` 桥已挂,未实测点击 | **B10**(桩触发权限工具 + 截图验收) | +| 3 | steer(轮内注入) | `session/steer-unavailable` 诚实拒绝 | **B11**(先 ADR/spec,再改引擎 StreamAsync) | +| 4 | `assistant-stream` 逐字流式 | `revision=0` | 依赖引擎增量事件边界,随 B11 一并评估 | +| 5 | terminal(10 方法)/ goals(7 方法+事件)/ workspaceFiles/changes | 未实现 | ⬜ 另立项目(需引擎大扩展),本任务不展开 | +| 6 | 审批**审计事件**(`approval/asked` + `approval/decided`)落会话日志 | 只发 `$events` 瞬时帧,无会话日志 | **B12**(补齐后历史回放与 DSH 一致) | + +## 三、批次计划(每批 红测试 → 实现 → 编译 → 绿测试 → 文档 → commit) + +| 批次 | 内容 | 状态 | +|------|------|------| +| B9 | E2E 脚本同步:`transcript.jsonl` 优先/`.json` 回退、播种改写 JSONL、exe 路径与文档对齐 | ✅ 已验收(见 §四) | +| B10 | 审批弹窗真机验收:桩返回需审批的工具调用 → 断言 `approval/request` waterfall → 前端面板 → `$events/result` 回帖 → 引擎按判定放行/拒绝 | ✅ 已验收(见 §五) | +| B12 | 审批审计事件补口:`approval/asked` + `approval/decided` 轮内成对落会话日志(含刷新回放) | ✅ 已验收(见 §六) | +| B11 | steer 引擎扩展:ADR 记录轮内注入点(Actor 邮箱/邮箱队列)→ `StreamAsync` 支持下一 step 注入 → 宿主 `mode:"steer"` 路由 + 事件投影 | ⬜ | + +## 四、B9 验收(四列) + +真机 exe 环境(ADR 0080): +- 隔离 home `%TEMP%\opencode\dsh-m2c2-e2e\home`(最小 `settings.json`:`vendor.stub → http://127.0.0.1:8899`,`STUB_API_KEY=stub-key`), + 工作区 `%TEMP%\opencode\dsh-m2c2-e2e\ws-m2c2`(`JCC_DIRECTORY_PICK` 注入),WebView2 目录 `...\webview2-b9`, + `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`。 +- 启动:`Start-Process artifacts\bin\NativeGui.Host\Debug\net10.0-windows10.0.26100.0\NativeGui.Host.exe`(默认即 DSH 前端)→ `node dumps/wait-webui.mjs`。 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| `TranscriptService.ResolveTranscriptFile`(`.jsonl` 优先、`.json` 回退,ADR 0144) | `dsh-webui-history.mjs` 播种 `sessions/history-e2e/transcript.jsonl` → 引擎读回 → 快照含 `user/message`+`assistant/message` → 界面渲染历史问答 | ✅ | ✅ ALL_PASS(16 项):`快照历史页非空且光标为最后 seq`、`历史页含用户与助手消息事件 turn/start,step/start,user/message,assistant/message,step/end,turn/end`、`界面渲染出历史问答` 全过 | +| 引擎写侧 JSONL 落盘 | `dsh-webui-prompt.mjs` 读 `sessions//transcript.jsonl` 断言用户/助手文本 | ✅ | ✅ ALL_PASS(23 项):`用户消息落盘到 transcript.jsonl`、`助手回复落盘到 transcript.jsonl bytes=974`、`sessions/prompt 受理回执 accepted`、`无流式错误帧` | +| exe 路径 / 启动方式对齐(`NativeGui.Host.exe`,默认 DSH 面) | `launch-webui.mjs`、`wait-webui.mjs` | ✅ | ✅ `GUI_READY`(超时由 15s 提到 45s:引擎组装 `CreateGuiSessionAsync` 实测 ~7.6s + WebView2 就绪,CDP 端口 ~17–20s 才开) | +| 回归基线(改协议层后必跑) | `tool/cancel/queue/reload` 四脚本 | ✅ | ✅ `dsh-webui-tool.mjs` ALL_PASS(工具卡片 `call_stub_1` state=error)、`dsh-webui-cancel.mjs` ALL_PASS(turn/end aborted)、`dsh-webui-queue.mjs` ALL_PASS(两条 queue 串行两轮)、`dsh-webui-reload.mjs` ALL_PASS(`session/page 页尾对齐 throughSeq=27`) | +| `make-jcc-home-messages.mjs` 双写 `gui.json`(旧存储回退)+ `transcript.jsonl`(引擎入口) | 自研面遗留夹具 / `dsh-jcc-home-messages` 目录 | ✅ | ⬜ 未跑(依赖已废弃的自研 WebUI 面,随 M3 删除再定去留) | + +### B9 遗留观察(不影响验收,记录备查) + +- **脚本无节制重载导致偶发 UI flake**:`dsh-webui-queue.mjs` 首次运行报 `element was detached from the DOM, retrying` 超时,重跑即 ALL_PASS。根因是点「发送」瞬间队列重排使节点被 React 重挂载,`page.click` 重试耗尽。属 E2E 脚本侧稳健性问题(工具目录 gitignored),后续可改为 `page.getByRole('textbox').fill + press('Enter')` 或 `locator.click({force:true})`。 +- **窗口尺寸被外部改小会让断言假失败**:实测 GUI 窗口曾从 1440×900 缩到 533×458,侧栏折叠成 56px rail,`工作区 ws-m2c2 就绪` 断言随之超时(与代码无关)。E2E 前应确认窗口尺寸;必要时脚本显式断言 `window.innerWidth >= 1000`。 + + +## 五、B10 验收(四列) + +验收脚本 `dumps/dsh-webui-approval.mjs`(工具目录,gitignored),同 §四 的隔离 home / 工作区 / CDP 环境,另加两项前置: + +- `home/settings.json`:`current.permissions.ask = ["read(approval-probe.txt)"]`(`PermissionConfig.AskRules` 在 **引擎启动时** 由 `LoadPermissionsFromSettingsAsync` 一次性装入,改后必须重启 exe)。 +- 工作区 `ws-m2c2/approval-probe.txt`,内容含哨兵串 `APPROVAL_PROBE_CONTENT_7f3a`。 + +桩新增分支:提示词含 `USE_APPROVAL_TOOL` 时返回**真实** `read` 工具调用(`{"file_path": }`),取代旧的假工具 `StubTool`——这样才会走引擎的路径权限守卫而不是「工具不存在」错误分支。 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| `PathPermissionMiddleware` 步骤4(`MatchRule` 命中 `Read+Ask` 规则 → `Ask`),早于步骤6「工作目录内 allow」 | 桩返回 `read(approval-probe.txt)` → 引擎在 `auto` 模式下仍判定 `PendingConfirmation` | ✅ | ✅ `request.toolName = read`、`request.reason = 读取路径 …\approval-probe.txt 需要用户确认。` | +| `DshApprovalBroker.RequestAsync`(构造 waterfall 帧,前端硬校验「必须且只能含 type/event/eventId/agentId/request」) | `$events` 流下发 `approval/request` | ✅ | ✅ 帧键集实测 `agentId,event,eventId,request,type`;`request` 不含 `agent`/`signal`;`agentId` = 会话 ID(`DshTurnRunner.SetAgent`) | +| `dsh-client-ui-approval` 面板渲染(`data-approval-key` + 「等待审批」条) | composer 内嵌审批面板 | ✅ | ✅ 出现 `data-approval-key=approval:1|2`,文案含「允许一次」「拒绝」;截图 `dumps/dsh-webui-approval-panel-{allow,reject}.png` | +| `$events/result` → `DshApprovalBroker.SettleAsync`(`allowed-once`=Allow,其余=Deny,clientId 校验 + 幂等) | 前端点按钮回帖 | ✅ | ✅ 两条路径都实测:`拒绝` → `tool/result.isError=true`,文案 `Error: 用户拒绝工具执行: read`,哨兵串**未**进事件流/界面;`允许一次` → `tool/result` 内容即文件内容(含哨兵串),界面 `[data-tool="read"][data-state="ok"]` | +| `GuiPermissionConfirmationHandler.ApproveDecision`(Allow → `ApproveToolTemporarily("read", 1min)` → 重跑管线放行) | 引擎重试管线,工具**真正执行** | ✅ | ✅ 允许后工具真实读到文件(哨兵串出现在 `tool/result` 与事件流);两轮均以 `turn/end{kind:"completed"}` 收尾;`ALL_PASS`(33 项,连续两次确定性通过) | + +### B10 遗留观察(不影响验收,记录备查) + +- **批准顺序有副作用,E2E 必须先拒绝后允许**:`ApproveToolTemporarily(toolName, 1min)` 按**工具名**缓存,先允许再发第二轮会让第二轮不再弹面板(被临时批准短路)。脚本因此固定 `reject → allow` 顺序;跨轮验证应用不同工具名或重启 exe。 +- **审批面板只关 UI 不等待 BP 层结果**:点击后面板立即消失(`aria-busy` 期间),断言必须转到 `tool/result` 事件而不是面板本身。 +- **`approval/asked` / `approval/decided` 审计事件未落会话日志**:`DshApprovalBroker` 当时只发 `$events` 瞬时帧,不回写 `approval/*` 会话事件;DSH 参考实现(`dsh-user-approval`)会在轮内落审计事件便于历史回放。**B12 已补齐**(见 §六)。 +- **测试桩触发判定改为「标记出现在最后一次 `"role":"tool"` 之后」**:原判定 `!body.includes('"role":"tool"')` 在多轮会话里会让第二轮起不再触发(历史含旧工具消息),改用 `lastIndexOf` 比较,`USE_TOOL` / `USE_APPROVAL_TOOL` 同时受益。 + + + + + + + + + + + + + + + +## 六、B12 验收(四列) + +补齐 §五 遗留缺口:审批**审计事件**落会话日志,使历史回放与 DSH 参考实现(`dsh-user-approval/lib/index.js`:轮内 `approval/asked` → 等结果 → `approval/decided`)一致。 +同一验收脚本 `dumps/dsh-webui-approval.mjs`(同 §五 的隔离 home / 工作区 / 桩分支)追加断言:两轮各 7 项审计断言 + 刷新重载 4 项回放断言,脚本现共 **48 项**。 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| `DshApprovalAskedData` / `DshApprovalDecidedData` DTO(`ToolName`/`CallId`/`Reason`/`Outcome`,`DshJsonContext` camelCase) | `approval/asked` / `approval/decided` 会话事件的序列化 | ✅ | ✅ 日志实测形状 `{"id":"11123f8d…","toolName":"read","callId":null,"reason":"读取路径 …\approval-probe.txt 需要用户确认。"}` 与 `{"id":"11123f8d…","outcome":"rejected"}`;`id` 非空、`decided.id == asked.id` | +| `DshSessionStreamHub.PublishElementAsync`(分配 seq + `DshSessionJournal` 落盘 + 推送订阅者) | 审计事件写会话日志并进入 `$events` 流 | ✅ | ✅ 事件流实测 `turn/start,step/start,user/message,tool/call,approval/asked,approval/decided,tool/result,assistant/message,step/end,turn/end` | +| `DshApprovalBroker.RequestAsync` / `SettleAsync` / `AppendAuditAsync`(`asked` 在发帧前落,`decided` 由 `TrySetResult` 赢家只落一次) | 每轮审批恰好一对审计事件 | ✅ | ✅ `asked` 紧跟 `tool/call`、`decided` 早于 `tool/result`;拒绝轮 `outcome=rejected`、允许轮 `outcome=allowed-once` | +| `DshTurnRunner.FinishTurnAsync`(**先** `Approvals.CloseTurnAsync` 结清审计对,**再** `EndTurnAsync` 落 `step/end`+`turn/end`),三个分支(completed/aborted/error)统一走它,`finally` 不再关轮 | 审计对必须被本轮 `hasOpenTurn` 包住(DSH invariant 否则抛 "outside an open turn"),取消/失败路径同样适用 | ✅ | ✅ 完成轮 `turn/start(=0) < asked(=4) < decided(=5) < turn/end(=9)`;取消轮改前 `decided(=8) > turn/end(=7)`(违规)→ 改后 `decided(=6) < turn/end(=8)` | +| `DshSessionStore.SnapshotAsync`(不过滤事件类型)+ 前端历史渲染 | 刷新后审计事件随快照回放 | ✅ | ✅ 刷新后回放到 `approval/asked`、`approval/decided + allowed-once`、以及 `rejected` 判定,界面同时渲染历史问答 | +| 单测 `DshApprovalBrokerTests` 新增 7 例(词表映射 / 无开放轮次跳过 / 监听者抛错→`unavailable` / 关轮→`cancelled` 且闭环 / 关轮等待断流补写屏障) | 审计语义的确定性验证(不依赖真机) | ✅ | ✅ `dotnet test native_gui/tests/join_code_gui.tests` = **277 通过 / 0 失败**(原 270 + 7);真机 `ALL_PASS`(48/48,连跑三次) | + +### B12 边界验收(detach / cancel,脚本 `dumps/dsh-webui-approval-edges.mjs`) + +非正常路径同样要求「询问与判定成对、且被本轮包住」。合并「最后一次 `snapshot.records`」与「live `$events` 事件帧」后断言 `turn/start < asked < decided < turn/end`、`decided.id == asked.id`、`outcome` 词表、工具 `isError`、哨兵未入流。 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| `DshApprovalBroker.Detach`(`$events` 断开 → 挂起请求 Deny + `unavailable` 审计)+ `_detachedAudit` 屏障(`Task.WhenAll` 累积 fire-and-forget 补写) | detach 路径:前端不回答直接刷新页面 | ✅ | ✅ `ALL_PASS`:`outcome=unavailable`、工具 `isError=true`、哨兵未入流、`0/4/5/9` 轮内闭合 | +| `CloseTurnAsync` 排空 `_detachedAudit` 屏障(同步 helper 取锁,规避 async 持锁 JCC4001) | 关轮返回时保证 `decided` 已落库,不会晚于 `turn/end` | ✅ | ✅ `ALL_PASS`:cancel 路径 `outcome=cancelled`、`turn/end{kind:"aborted",reason:{kind:"user"}}`、`running=false`、`0/4/6/8` 轮内闭合 | +| 红→绿证据 | 缺陷驱动(TDD 铁律2) | ✅ | ✅ 先红:新单测 `CloseTurn_WaitsForDetachedAudit_SoPairStillLandsInsideTurn` 失败(`失败: 1,通过: 17,总计: 18`);后绿:同过滤集 `18/18`,全量 `277/0` | + +回归(本轮改动后全跑):`approval` 48/48、`edges` ALL_PASS、`prompt`/`tool`/`queue`/`cancel`/`reload`/`history` 全 `ALL_PASS`;`NativeGui.Host` Debug 编译 0 警告。 + +### B12 遗留观察(不影响验收,记录备查) + +- **审计写入失败不阻塞审批**:`AppendAuditAsync` 只记日志(无会话 ID / 无 sink / 序列化异常都吞掉),放行流程永远不被日志问题拖死。 +- **孤儿审批后台补写**:`$events` 断开时挂起请求结算为 `unavailable`,审计补写 fire-and-forget(释放函数禁阻塞,ADR 0093);但**关轮必须排空它**,否则 `decided` 会落到 `turn/end` 之后(本轮修复的正是这条)。 +- **取消路径比正常路径更易暴露顺序缺陷**:取消时工具结果先回(`tool/result`)、轮次立即收尾,若关轮晚于 `turn/end` 则必然违规;正常路径因工具执行耗时掩盖了竞态,故必须单独造 cancel/detach 边界脚本。 +- **E2E 选工作区必须重载页面(脚本侧)**:前端点选工作区后 composer 阶段不当帧刷新(实测停在 `data-phase=plain`),`ensureWorkspace` 统一为「点菜单项 → `page.reload()` → 等可编辑」;并容错「异步恢复会话时选择入口闪现又消失(点击途中 `element was detached`)」。自查发现 `dsh-webui-prompt.mjs` 重载后未等输入框就断言(无 `until` 的竞态),已改为确定性等待。 +- **`dsh-webui-reload.mjs` 去掉隐式耦合**:原脚本假设「侧栏第一行 = 上一条脚本留下的会话」,会话数一多就漂移。改为自建内容(普通一轮 + `USE_TOOL` 一轮)后再刷新断言,并可独立运行(自带 `ensureStub`);默认会话还原已渲染历史内容时不再要求点侧栏行。 +- **窗口尺寸漂移是所有侧栏断言的共同假失败源**:真实窗口实测从 1440×900 漂到 575×425 时侧栏折叠成 rail(`div[class*=sessionRow]` 数为 0),`dsh-webui-history.mjs` 因此连续两次假失败。已在 `_e2e.mjs#connectDshPage` 统一 `page.setViewportSize({1440,900})` 固定视口,并把 `history.mjs` 从自建 CDP 连接迁到共享连接;固定 5s 等待改为等「点行后的打开快照」到达。 +- **单脚本执行必须限时**:脚本收尾 `cdp.close()` 偶发不返回(工作已完成但进程不退出),运行方对每个脚本设硬超时(≤3 分钟)并读日志判定,禁止无上限等待。 + + + + + + + + + + + + + + +## 七、B11 steer(轮内注入)计划与验收 + +DSH 语义(权威源 `dsh-agent-loop/lib/index.js:998-1006`、`:1061`、`dsh-api-session-controller/lib/index.js:882`)与落地方案见 [ADR 0146](../../proposed/adr/0146-steer-in-turn-injection-mailbox.md):**邮箱投递(宿主)→ 步首 drain(引擎)→ 轮内 `user/message` 事件(宿主投影)**,且「模型本步无工具调用但邮箱非空」不结束轮次。 + +### 批次步骤(红 → 实现 → 编译 → 绿 → 文档 → commit) + +| 步骤 | 内容 | 状态 | +|------|------|------| +| B11-a | 契约与 ADR:`ISteerInputMailbox` + `MessageOriginKind.SteerInput` + `ChatStreamEventType.SteerInput`(ADR 0146 proposed) | ✅ | +| B11-b | E2E 红:`dsh-webui-steer.mjs`(桩按注入文本应答;断言受理、轮内 `user/message`、单轮、下一步请求含注入文本) | ✅ 红已确认 | +| B11-c | 引擎单元红→绿:步首 drain 进下一次请求 + 有待注入不收尾 | ✅ 单测 2 例(红:CS0246/CS1739/CS0117 → 绿 9/9) | +| B11-d | 宿主路由绿:`session/prompt mode=steer`(运行中受理 / 空闲回落开轮)+ `updateQueue action.kind=steer` 升级 | ✅ WebHost 单测 142/0 | +| B11-e | 全量回归(approval/edges/prompt/tool/queue/cancel/reload/history)+ 文档四列表 + commit | ✅ 9 脚本全 ALL_PASS | + +### B11-b 红灯证据(真机 exe + 桩,非 mock) + +E2E 场景(`dumps/dsh-webui-steer.mjs`,桩加两个 marker): + +1. 桩 `USE_STEER_WAIT`:工具结果之后的这一步**挂起 6s**(`STUB_STEER_HOLD_MS`)再回一句**无工具调用**的正文 —— 制造确定性的「轮次仍在运行」窗口,同时天然命中「本步无工具调用但有待注入」分支。 +2. 轮内 `session/prompt {mode:"steer", content:[STEER_MARK ...]}`。 +3. 桩 `STEER_MARK`:请求体在**最后一次工具结果之后**出现注入文本时回 `steer 已收到:轮内注入成功`(证明确实是注入文本驱动了下一步)。 + +红灯矩阵(`FAILED 9`,脚本自行退出): + +| 断言 | 结果 | 实测 | +|------|------|------| +| `session/prompt mode=steer` 受理 | ❌ | `{"code":"session/busy","message":"会话 … 上一轮仍在生成;… 排队请用 mode=queue"}` | +| 得到 accepted(非 `steer-unavailable`/`busy`) | ❌ | `session/busy` | +| 本轮收尾 `turn/end` | ✅ | `turn/start,step/start,user/message,tool/call,tool/result,assistant/message,step/end,turn/end` | +| 注入文本进入事件流 | ❌ | 无 `STEER_MARK` | +| 注入未开启新轮次(单 `turn/start`) | ✅ | `turn/start x1` | +| 注入落轮内 `user/message`(`surfaceOp=append`) | ❌ | `index=-1` | +| 注入在工具结果之后 / 最终回复之前 | ❌ | `tool/result=4 steer=-1 ack=-1` | +| 最终回复来自注入分支 | ❌ | 无 ack | +| 界面渲染注入文本 | ❌ | 无 | + +关键旁证:桩日志本轮只有 **2 次** `/chat/completions` 请求(无第三次带 `STEER_MARK` 的请求)→ 注入从未抵达引擎,问题在宿主受理层而非事件投影层。 + +**结论(对齐 DSH 的差距)**:宿主把 `mode:"steer"` 当成「非 queue 的普通 prompt」落到 `session/busy`,既没受理也没按 DSH 语义投递;引擎侧完全没有轮内注入通道。 + +### E2E 工具链修正(三次试跑踩出来的) + +1. **红灯脚本禁把「预期为红」的断言写成会抛错的 `until`** —— 第一个不满足就抛异常中断,后半段报告全丢,看起来像「卡住」。改为有界软等待 `waitFor()`(内部 catch `until` 超时,返回 bool)+ `check()`,一次运行输出完整矩阵;整脚本再套 `try/catch` → `check('流程无异常中断')`,保证必定走到 `finish()`/`process.exit()`。 +2. **桩进程必须先按命令行清理再启动** —— `_e2e.mjs#ensureStub` 无条件 `spawn` 新桩,若旧桩仍占 8899,新桩静默 `EADDRINUSE` 而探活仍通过 → 跑的就是**旧代码**(改了 marker 却不生效)。跑前先 `Get-CimInstance Win32_Process` 匹配 `stub-openai-server.mjs` 杀掉。 +3. **exe 必须由「短 bash 调用」启动** —— 单次 bash 超时被掐时整个进程树(含 `Start-Process` 的 exe)一起死;把「起 exe + wait-webui」与「跑 E2E」拆成两次调用,单次都在限时内结束。 +4. **拿会话 ID 别依赖 `session/list` 的 `running`** —— 该字段恒为 `false`(`DshSessionStore.ProjectAsync` 里写死),改为取 `session/follow` 快照的 `header.id`,回退 `session/list` 首条。 + +### B11-e 绿灯证据(真机 exe + 桩,非 mock) + +| 断言 | 结果 | 实测 | +|------|------|------| +| `session/prompt mode=steer` 受理 | ✅ | `{"accepted":true}` | +| 注入文本进入事件流(驱动下一步) | ✅ | 桩回 `steer 已收到:轮内注入成功` | +| 注入未开启新轮次 | ✅ | `turn/start x1`,`RunningTurn=1` | +| 注入落轮内 `user/message`(`surfaceOp=append`) | ✅ | `index=5` | +| 注入在工具结果之后 / 最终回复之前 | ✅ | `tool/result=4 < steer=5 < ack=6 < turn/end=8` | +| 界面渲染注入文本 | ✅ | 侧栏/气泡均出现注入文本 | + +- 事件序:`turn/start,step/start,user/message,tool/call,tool/result,user/message,assistant/message,step/end,turn/end`;`dsh-webui-steer.mjs` **ALL_PASS,9.6s**(`lifecycle` 看门狗:单步 10s / 总预算 60s)。 +- 单元:`QueryLoopMiddlewareTests` 9/9(`drain→AddUserMessage(SteerInput)→下一步 LLM`、`无工具调用但邮箱非空→不收尾`)。 +- 宿主管辖:`DshSteerInputTests` 3 例(运行中注入不新开轮 / 空闲回落开轮 / 引擎无邮箱 → `session/steer-unavailable` 且不排队)+ `DshUpdateQueueTests` steer 正反 2 例(投递即出队 / 不可收时消息留在队列)。 +- 全量回归(同一 exe + 桩):`prompt / tool / queue / cancel / reload / history / approval / approval-edges` 全 `ALL_PASS`。 + +### 验收四列表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| `ISteerInputMailbox`(按会话寻址的 FIFO 邮箱,Actor 邮箱模型;`SteerInputMailbox` 单例 + 同步 lock) | 宿主投递(`JccChatSession.TrySteerInput`)/ 引擎步首 drain | ✅ | ✅ `dsh-webui-steer.mjs` ALL_PASS + `DshSteerInputTests`([ADR 0080](../../implemented/adr/engineering_ci_test/0080-manual-exe-testing-guide.md) 手动 exe 真机) | +| `QueryLoopMiddleware` 步首 drain → `AddUserMessageAsync(SteerInput)` + `ChatStreamEventType.SteerInput` | 下一步 LLM 请求携带注入文本;宿主投影 `user/message` | ✅ | ✅ `QueryLoopMiddlewareTests` 9/9 + 真机事件 `steer=5`(注入驱动桩回 ack) | +| 收尾分支「邮箱非空则继续下一步」(对齐 DSH `next-step` 非空不结束轮) | 模型最后一步收到 steer 仍继续 | ✅ | ✅ `InvokeAsync_TextOnlyResponseButSteerPending_DoesNotEndTurn` + 真机单 `turn/start` 内含注入 | +| `DshTurnRunner`/`DshRpcDispatcher` steer 路由(替换 `session/steer-unavailable` 的旧拒绝语义) | 前端/协议 `session/prompt mode=steer`、`updateQueue action.kind=steer` | ✅ | ✅ WebHost 单测 142/0(含正反例)+ `dsh-webui-queue.mjs` ALL_PASS | + +### B11 遗留观察(不影响验收,记录备查) + +1. **测试项目已重组**:`native_gui/tests/join_code_gui.tests` → `native_gui/tests/native_gui.core.tests`(`NativeGui.Core.Tests.csproj`,程序集名仍 `JoinCode.Gui.Tests`)。本文档历史段落里的旧路径保留原样,实际运行请用新路径。 +2. **`session/list` 的 `Running` 恒 `false`**(`DshSessionStore.ProjectAsync` 写死),别用它判断在跑,取 `Streams.RunningTurn` 或 `session/follow` 快照。 +3. **`artifacts/bin` 曾整目录消失 / 宿主进程意外退出**(两次,原因未查明,疑似外部清理或用户侧操作):表现是 E2E 报 `Target page, context or browser has been closed` 或找不到 exe。处置:重新 `dotnet build native_gui/apps/NativeGui.Host/NativeGui.Host.csproj -c Debug` + 重启 exe,同一调用里 `Test-Path` 确认。 +4. **转录落盘与界面渲染无强顺序**:`dsh-webui-prompt.mjs` 曾出现「界面已渲染但 `transcript.jsonl` 还没写完」的假红(`bytes=871`)。已改为 `until` 轮询文件内容(禁固定 sleep)。 + +## 八、B13 assistant-stream 逐字流式(计划与验收) + +DSH 语义与落地方案见 [ADR 0147](../../proposed/adr/0147-assistant-stream-transient-frames.md):**引擎 delta(宿主)→ 瞬态 start/chunk/end 帧 → 前端 `assistant/live-chunk` 逐字渲染**,帧不落会话日志(重连靠快照 `assistantStream.activeAttempt` 重建)。 + +### 批次步骤(红 → 实现 → 编译 → 绿 → 文档 → commit) + +| 步骤 | 内容 | 状态 | +|------|------|------| +| B13-a | 契约与 ADR:帧/基线 DTO + `assistantStream` 严格 revision 密度规则(ADR 0147 proposed) | ⬜ | +| B13-b | E2E 红:`dsh-webui-assistant-stream.mjs`(慢桩逐字 → 断言快照基线、帧序 start→chunk*→end、partial 文本、end.seq 指向持久事件) | ⬜ | +| B13-c | 宿主单元红→绿:hub 状态机(revision 单调 + 基线重建)+ TurnRunner delta→帧翻译 | ⬜ | +| B13-d | 全量回归 + 文档四列表 + commit | ⬜ | + +### 验收四列表 + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|----------|--------|--------|--------| +| hub 瞬态帧通道(不占 seq、不落 journal,revision 严格 +1) | `session/follow` 推送 | ⬜ | ⬜ | +| 快照 `assistantStream` 基线(`activeAttempt` + 紧凑原始 chunk 流) | 中途打开/刷新的前端重建未完成文本 | ⬜ | ⬜ | +| `DshTurnRunner` delta → `start/chunk/end`(reasoning=index 0、text=index 1,committed 带 `assistant/message` 的 seq) | 前端 `assistant/live-chunk` 逐字渲染 + 结算替换 | ⬜ | ⬜ | diff --git a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ChatStreamEvent.cs b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ChatStreamEvent.cs index a8c9d7de04..0ab9316a0e 100644 --- a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ChatStreamEvent.cs +++ b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ChatStreamEvent.cs @@ -13,7 +13,9 @@ public enum ChatStreamEventType { [EnumValue("complete")] Complete, [EnumValue("tombstone")] Tombstone, [EnumValue("agentStarted")] AgentStarted, - [EnumValue("agentFinished")] AgentFinished + [EnumValue("agentFinished")] AgentFinished, + /// 轮内注入的输入已在步首进入上下文(宿主据此投影轮内 user/message) + [EnumValue("steerInput")] SteerInput } public sealed class ChatStreamEvent { @@ -184,6 +186,13 @@ public static ChatStreamEvent AgentFinished( Content = finalOutput }; + /// 创建轮内注入事件 — 注入文本已在该 step 开头写入上下文(宿主投影为轮内 user/message)。 + /// 注入的用户输入文本 + public static ChatStreamEvent SteerInput(string content) => new() { + Type = ChatStreamEventType.SteerInput, + Content = content + }; + /// 按事件类型匹配并返回结果。 public T Match( Func onText, @@ -195,7 +204,8 @@ public T Match( Func onTimingSummary, Func onDone, Func? onAgentStarted = null, - Func? onAgentFinished = null) { + Func? onAgentFinished = null, + Func? onSteerInput = null) { return Type switch { ChatStreamEventType.Content => onText(Content ?? string.Empty), ChatStreamEventType.Thinking => onThinking(ThinkingContent ?? string.Empty), @@ -211,6 +221,9 @@ public T Match( ChatStreamEventType.AgentFinished => onAgentFinished is not null ? onAgentFinished(AgentId ?? string.Empty, AgentSuccess) : throw new InvalidOperationException($"AgentFinished 事件需要 {nameof(onAgentFinished)} 回调"), + ChatStreamEventType.SteerInput => onSteerInput is not null + ? onSteerInput(Content ?? string.Empty) + : throw new InvalidOperationException($"SteerInput 事件需要 {nameof(onSteerInput)} 回调"), _ => throw new InvalidOperationException($"Unknown event type: {Type}") }; } @@ -226,7 +239,8 @@ public void Switch( Action onTimingSummary, Action onDone, Action? onAgentStarted = null, - Action? onAgentFinished = null) { + Action? onAgentFinished = null, + Action? onSteerInput = null) { switch (Type) { case ChatStreamEventType.Content: onText(Content ?? string.Empty); @@ -259,6 +273,10 @@ public void Switch( case ChatStreamEventType.AgentFinished: onAgentFinished?.Invoke(AgentId ?? string.Empty, AgentSuccess); break; + // 轮内注入在未提供回调时静默忽略 — 既有 CLI 消费方(未启用 steer)零改动 + case ChatStreamEventType.SteerInput: + onSteerInput?.Invoke(Content ?? string.Empty); + break; default: throw new InvalidOperationException($"Unknown event type: {Type}"); } diff --git a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ISteerInputMailbox.cs b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ISteerInputMailbox.cs new file mode 100644 index 0000000000..621d7a7009 --- /dev/null +++ b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/ISteerInputMailbox.cs @@ -0,0 +1,28 @@ +namespace JoinCode.Abstractions.LLM.Chat; + +/// +/// 轮内注入邮箱(Actor 邮箱模型)— 轮次运行期间把用户输入投递给**当前轮的下一个 step**, +/// 而不是排队等下一轮。对齐 DSH agent.steer(input) → send(input,'next-step',true) +/// (dsh-agent-loop/lib/index.js:809)与「本步无工具调用但 next-step 非空 → 不结束轮次」 +/// (:998-1006)。 +/// +/// +/// 按会话寻址:宿主只会往「该会话」的邮箱投递,引擎在每步开头 drain 本会话, +/// 因此子代理/其它会话的注入不会串味。实现必须是线程安全的(宿主线程投递、引擎线程取件)。 +/// +public interface ISteerInputMailbox { + /// 把一段用户输入投递到该会话的注入邮箱(空文本忽略)。 + /// 目标会话 ID + /// 用户输入文本 + void Enqueue(string sessionId, string text); + + /// 该会话是否有待注入的输入(步末「不结束轮次」判断用)。 + /// 目标会话 ID + /// 有待注入返回 true + bool HasPending(string sessionId); + + /// 取走该会话全部待注入输入(FIFO 顺序,取走后邮箱清空)。 + /// 目标会话 ID + /// 按投递顺序排列的输入文本;无待注入返回空集合 + IReadOnlyList DrainAll(string sessionId); +} diff --git a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/MessageOriginKind.cs b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/MessageOriginKind.cs index 75277c0fab..58ece3617a 100644 --- a/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/MessageOriginKind.cs +++ b/modules/abstractions/abstractions/abs_ai/llm/chat/chat_core/MessageOriginKind.cs @@ -8,4 +8,6 @@ public enum MessageOriginKind { [EnumValue("task-notification")] TaskNotification, [EnumValue("coordinator")] Coordinator, [EnumValue("channel")] Channel, + /// 轮内注入(steer)— 轮次运行中用户插入的输入,在下一 step 开头进入上下文 + [EnumValue("steer")] SteerInput, } \ No newline at end of file diff --git a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs index 1125558ddb..e08847d2f1 100644 --- a/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs +++ b/modules/async_primitives/async_lock.tests/actor/AskWithRetryTest.cs @@ -155,13 +155,22 @@ public async Task NonIdempotent_AllowTrue_SucceedsWithWarning() { result.Should().Be("ok"); } - /// 验证幂等命令 allowNonIdempotentRetry=false 不抛异常 + /// 验证幂等命令 allowNonIdempotentRetry=false 不抛异常 + /// flaky 守卫(同族先例 FirstAttemptTimesOut_Retry16Times):契约是"不抛异常且成功", + /// 第几次成功是实现细节 — CI 高压下首次 Ask 可撞 2s 超时(run 39656665694 实收 success-at-2); + /// 前缀断言 + 16 次重试,全部失败才判红。 [Fact] - public async Task Idempotent_AllowFalse_Succeeds() { - await using var actor = new RetryDemoActor(); - actor.SetSuccessAfter(0); - var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 2000, maxRetries: 3); - result.Should().Be("success-at-1"); + [Trait("Category", "Flaky")] + public async Task Idempotent_AllowFalse_Succeeds_Retry16Times() { + for (var attempt = 0; attempt < 16; attempt++) { + await using var actor = new RetryDemoActor(); + actor.SetSuccessAfter(0); + var result = await actor.AskWithRetryTestAsync(singleTimeoutMs: 2000, maxRetries: 3); + if (result.StartsWith("success-at-", StringComparison.Ordinal)) { + return; + } + } + throw new Xunit.Sdk.XunitException("16次重试均失败: 幂等命令 allowNonIdempotentRetry=false 未能成功"); } } diff --git a/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs b/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs index 364f7db08e..7d76f79428 100644 --- a/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs +++ b/modules/cognition/brain.context.tests/core/o_to_t/QueryLoopMiddlewareTests.cs @@ -268,6 +268,115 @@ private static QueryLoopMiddleware CreateMiddleware( ToolUseContext = new ToolUseContext() }; + private static ChatMiddlewareContext CreateContext(string sessionId) => new() { + Message = "test", + ToolUseContext = new ToolUseContext(), + SessionId = sessionId + }; + + /// B11 steer:轮内注入的文本必须在**步首**落成用户消息(下一步请求才带得上), + /// 并以 事件流式通知宿主投影轮内 user/message。 + /// 对齐 DSH `dsh-agent-loop` 步首 `session.append("user/message", message)`。 + [Fact] + public async Task InvokeAsync_SteerPending_AddsUserMessageBeforeNextLlmCall_AndEmitsSteerInput() { + const string sessionId = "session-steer-1"; + var mailbox = new SteerInputMailbox(); + mailbox.Enqueue(sessionId, "STEER_MARK 轮内注入"); + + var order = new List(); + var userMessages = new List<(string Content, MessageOriginKind? Origin)>(); + var contextManager = new Mock(); + contextManager.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new MessageList()); + contextManager.Setup(c => c.AddUserMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((content, origin, _) => { + userMessages.Add((content, origin)); + order.Add("add-user"); + }) + .Returns(Task.CompletedTask); + contextManager.Setup(c => c.AddAssistantMessageAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + contextManager.Setup(c => c.SyncDiscoveredToolsFromHistoryAsync(It.IsAny())).Returns(Task.CompletedTask); + + var llmHandler = new Mock(); + llmHandler.Setup(h => h.InvokeLLMAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, _, state, _, _) => { + order.Add("llm"); + state.FullResponse.Append("收到注入"); + }) + .Returns(AsyncEnumerableEmpty()); + + await using var middleware = new QueryLoopMiddleware( + new BackgroundNotificationHandler(contextManager.Object), + llmHandler.Object, + new ToolExecutionHandler(new Mock().Object, contextManager.Object), + new TelemetryRecorder(), + contextManager.Object, + new EmptyResponseTracker(), + steerMailbox: mailbox); + + var events = new List(); + await foreach (var evt in middleware.InvokeAsync( + CreateContext(sessionId), (ctx, ct) => AsyncEnumerableEmpty(), CancellationToken.None)) { + events.Add(evt); + } + + userMessages.Should().ContainSingle(m => m.Content == "STEER_MARK 轮内注入" && m.Origin == MessageOriginKind.SteerInput); + order.Should().Equal("add-user", "llm"); + events.Should().ContainSingle(e => e.Type == ChatStreamEventType.SteerInput && e.Content == "STEER_MARK 轮内注入"); + mailbox.HasPending(sessionId).Should().BeFalse("注入被 step 首取走后邮箱必须清空"); + } + + /// B11 steer:模型本步**无工具调用**但邮箱非空时不得结束轮次(对齐 DSH + /// `dsh-agent-loop`:998-1006 —— 否则轮内注入会被丢弃/拖到下一轮)。 + [Fact] + public async Task InvokeAsync_TextOnlyResponseButSteerPending_DoesNotEndTurn() { + const string sessionId = "session-steer-2"; + var mailbox = new SteerInputMailbox(); + var llmCalls = 0; + var contextManager = new Mock(); + contextManager.Setup(c => c.GetMessageListAsync(It.IsAny())).ReturnsAsync(new MessageList()); + contextManager.Setup(c => c.AddUserMessageAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + contextManager.Setup(c => c.AddAssistantMessageAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + contextManager.Setup(c => c.SyncDiscoveredToolsFromHistoryAsync(It.IsAny())).Returns(Task.CompletedTask); + + var llmHandler = new Mock(); + llmHandler.Setup(h => h.InvokeLLMAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (_, _, _, _, state, _, _) => { + llmCalls++; + if (llmCalls == 1) { + // 模拟用户在本步 LLM 调用**进行中**键入 steer:步末检查邮箱时才发现 + state.FullResponse.Append("第一次回复(无工具调用)"); + mailbox.Enqueue(sessionId, "STEER_MARK 迟到的注入"); + } + }) + .Returns(AsyncEnumerableEmpty()); + + await using var middleware = new QueryLoopMiddleware( + new BackgroundNotificationHandler(contextManager.Object), + llmHandler.Object, + new ToolExecutionHandler(new Mock().Object, contextManager.Object), + new TelemetryRecorder(), + contextManager.Object, + new EmptyResponseTracker(), + steerMailbox: mailbox); + + var events = new List(); + await foreach (var evt in middleware.InvokeAsync( + CreateContext(sessionId), (ctx, ct) => AsyncEnumerableEmpty(), CancellationToken.None)) { + events.Add(evt); + } + + llmCalls.Should().Be(2, "有待注入时必须继续下一步,而不是在文本回复处收尾"); + events.Should().Contain(e => e.Type == ChatStreamEventType.SteerInput && e.Content == "STEER_MARK 迟到的注入"); + mailbox.HasPending(sessionId).Should().BeFalse(); + } + /// TASK061 请求前上下文预算守卫(孤儿接线 — DecidePreflight 此前无消费方): /// 估算比率 ≥ 1.0(消息+工具已超模型上下文窗口本体)→ 直接抛 ContextOverflowException ///(不发 API — 用户要求"超 1M 窗口直接报错";错误带估算/窗口/诱导清上下文)。 diff --git a/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs b/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs index 3285ec70ae..3be12b864d 100644 --- a/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs +++ b/modules/cognition/brain/context/services/chat/core/QueryLoopMiddleware.cs @@ -30,6 +30,7 @@ public sealed partial class QueryLoopMiddleware : ServiceEntity, IChatMiddleware private readonly IToolConcurrencyClassifier? _concurrencyClassifier; private readonly ToolExecutionSettings? _toolExecutionSettings; private readonly ILogger? _logger; + private readonly ISteerInputMailbox? _steerMailbox; /// /// 初始化查询循环中间件,注入各处理器和可选服务 @@ -45,6 +46,7 @@ public sealed partial class QueryLoopMiddleware : ServiceEntity, IChatMiddleware /// 工具并发安全性分类器(可选) /// 工具执行设置(可选) /// 可选日志记录器 + /// 轮内注入邮箱(可选;null 时 steer 事件永不产生,等价旧行为) public QueryLoopMiddleware( IBackgroundNotificationHandler notificationHandler, ILLMInvocationHandler llmHandler, @@ -56,7 +58,8 @@ public QueryLoopMiddleware( ILoopDetectionStrategy? loopDetectionStrategy = null, IToolConcurrencyClassifier? concurrencyClassifier = null, ToolExecutionSettings? toolExecutionSettings = null, - ILogger? logger = null) { + ILogger? logger = null, + ISteerInputMailbox? steerMailbox = null) { _notificationHandler = notificationHandler; _llmHandler = llmHandler; _toolHandler = toolHandler; @@ -67,6 +70,7 @@ public QueryLoopMiddleware( _concurrencyClassifier = concurrencyClassifier; _toolExecutionSettings = toolExecutionSettings; _logger = logger; + _steerMailbox = steerMailbox; _loopDetectionStrategy = loopDetectionStrategy ?? new InformationEntropyGuardian(logger: logger); } @@ -124,6 +128,13 @@ public async IAsyncEnumerable InvokeAsync( await _notificationHandler.ProcessPendingNotificationsAsync(ct).ConfigureAwait(false); + // B11 steer:步首把轮内注入的输入落成用户消息(对齐 DSH dsh-agent-loop:1061 + // 「步首 session.append("user/message", message)」)——下一步请求才带得上。 + // 事件后续由宿主投影成轮内 user/message(surfaceOp=append)。 + foreach (var evt in await DrainSteerInputsAsync(context, ct).ConfigureAwait(false)) { + yield return evt; + } + var historySnapshot = await _contextManager.GetMessageListAsync(ct).ConfigureAwait(false); Diag.WriteLine($"[LOOP] 迭代 #{totalToolCalls} | 消息={historySnapshot.Count}"); @@ -161,6 +172,12 @@ public async IAsyncEnumerable InvokeAsync( if (!string.IsNullOrWhiteSpace(iterState.FullResponse.ToString())) { _emptyResponseTracker.Reset(); + // B11 steer:本步无工具调用,但邮箱里还有待注入输入 → 不得收尾(DSH :998-1006), + // 否则注入会被丢弃或拖到下一轮。下一步步首统一 drain。 + if (HasPendingSteerInput(context)) { + Diag.WriteLine($"[LOOP {iterState.CallId}] 本步无工具调用但存在轮内注入, 继续下一步"); + continue; + } break; } @@ -411,6 +428,33 @@ await _contextManager.AddToolResultMessageAsync( } } + /// 该会话是否有待注入输入(未装配邮箱时恒 false,等价旧行为)。 + /// 当前中间件上下文(提供会话 ID) + private bool HasPendingSteerInput(ChatMiddlewareContext context) => + _steerMailbox?.HasPending(context.SessionId) == true; + + /// 取走本会话全部轮内注入,按 FIFO 落为用户消息并返回对应事件。 + /// 当前中间件上下文(提供会话 ID) + /// 取消令牌 + /// 每条注入对应一个 事件 + private async Task> DrainSteerInputsAsync( + ChatMiddlewareContext context, CancellationToken ct) { + if (_steerMailbox is null) { + return []; + } + var pending = _steerMailbox.DrainAll(context.SessionId); + if (pending.Count == 0) { + return []; + } + var events = new List(pending.Count); + foreach (var text in pending) { + await _contextManager.AddUserMessageAsync(text, MessageOriginKind.SteerInput, ct).ConfigureAwait(false); + Diag.WriteLine($"[STEER] 轮内注入进入上下文 session={context.SessionId} 长度={text.Length}"); + events.Add(ChatStreamEvent.SteerInput(text)); + } + return events; + } + private (List Events, string FinalResponse) BuildPureTextResponse( IterationState iterState, ChatMiddlewareContext context) { var events = new List(); diff --git a/modules/cognition/brain/context/services/chat/core/SteerInputMailbox.cs b/modules/cognition/brain/context/services/chat/core/SteerInputMailbox.cs new file mode 100644 index 0000000000..d11b9f4e76 --- /dev/null +++ b/modules/cognition/brain/context/services/chat/core/SteerInputMailbox.cs @@ -0,0 +1,50 @@ +namespace Core.Context; + +/// +/// 轮内注入邮箱实现 — 按会话隔离的 FIFO 队列(Actor 邮箱模型:投递方只入队,引擎步首 drain)。 +/// 取件与投递都是 O(1) 均摊;锁只在一次入队/取件内持有(无 await,天然不可重入)。 +/// +[Register(typeof(ISteerInputMailbox), ServiceLifetime.Singleton)] +public sealed class SteerInputMailbox : ISteerInputMailbox { + private readonly Dictionary> _queues = new(StringComparer.Ordinal); + private readonly object _gate = new(); + + /// + public void Enqueue(string sessionId, string text) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + if (string.IsNullOrWhiteSpace(text)) { + return; + } + lock (_gate) { + if (!_queues.TryGetValue(sessionId, out var queue)) { + queue = new Queue(); + _queues[sessionId] = queue; + } + queue.Enqueue(text); + } + } + + /// + public bool HasPending(string sessionId) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_gate) { + return _queues.TryGetValue(sessionId, out var queue) && queue.Count > 0; + } + } + + /// + public IReadOnlyList DrainAll(string sessionId) { + ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); + lock (_gate) { + if (!_queues.TryGetValue(sessionId, out var queue) || queue.Count == 0) { + return []; + } + var drained = new string[queue.Count]; + for (var index = 0; index < drained.Length; index++) { + drained[index] = queue.Dequeue(); + } + _queues.Remove(sessionId); + return drained; + } + } +} diff --git a/modules/storage/clock.tests/goal/engine/GoalHeartbeatTests.cs b/modules/storage/clock.tests/goal/engine/GoalHeartbeatTests.cs index da8a60dfd7..28c9321d55 100644 --- a/modules/storage/clock.tests/goal/engine/GoalHeartbeatTests.cs +++ b/modules/storage/clock.tests/goal/engine/GoalHeartbeatTests.cs @@ -153,14 +153,31 @@ public async Task HeartbeatCallback_IsInvoked_Periodically() { await heartbeat.StopActivityAsync(SessionActivityReason.ApiCall).ConfigureAwait(true); } + /// 回调抛异常不炸循环 — 确定性信号 + Retry16(flaky 根治,AGENTS 规范) + /// 原 150ms 固定窗两缺陷:①RegisterCallback 是 TrySend(fire-and-forget),与 Start 命令 + /// 乱序时首个 tick 前回调未注册(run 39662954991 实证 callCount=0)②窗内回调未触发即假红。 + /// 修法:回调进站即 Set 信号量(注册+触发双确认),20s 收敛窗;未触发换新实例最多 16 次。 [Fact] - public async Task HeartbeatCallback_Exception_DoesNotCrashLoop() { + [Trait("Category", "Flaky")] + public async Task HeartbeatCallback_Exception_DoesNotCrashLoop_Retry16Times() { + for (var attempt = 0; attempt < 16; attempt++) { + if (await TryHeartbeatExceptionLoopOnceAsync()) { + return; + } + } + throw new Xunit.Sdk.XunitException("16次重试均失败: 20s 信号窗内回调从未触发(注册/定时链断裂)"); + } + + /// 单轮异常回调循环验证 — false = 本轮信号窗内回调未触发(注册乱序,非断言失败) + private static async Task TryHeartbeatExceptionLoopOnceAsync() { + var called = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var callCount = 0; await using var heartbeat = CreateHeartbeat(TimeSpan.FromMilliseconds(50)); heartbeat.RegisterCallback(_ => { - callCount++; - if (callCount == 1) { + var count = Interlocked.Increment(ref callCount); + called.TrySetResult(); + if (count == 1) { throw new InvalidOperationException("test"); } @@ -169,11 +186,16 @@ public async Task HeartbeatCallback_Exception_DoesNotCrashLoop() { await heartbeat.StartActivityAsync(SessionActivityReason.ApiCall).ConfigureAwait(true); - await Task.Delay(150).ConfigureAwait(true); + try { + await called.Task.WaitAsync(TimeSpan.FromSeconds(20)).ConfigureAwait(true); + } catch (TimeoutException) { + return false; + } - Assert.True(callCount >= 1); + Assert.True(Volatile.Read(ref callCount) >= 1); await heartbeat.ResetAsync().ConfigureAwait(true); + return true; } [Fact] diff --git a/modules/storage/downloader.tests/DownloadSessionStateTransitionTests.cs b/modules/storage/downloader.tests/DownloadSessionStateTransitionTests.cs index d62b1820d5..2f5cde9dc5 100644 --- a/modules/storage/downloader.tests/DownloadSessionStateTransitionTests.cs +++ b/modules/storage/downloader.tests/DownloadSessionStateTransitionTests.cs @@ -96,18 +96,37 @@ public async Task LargeFile_MultiThread_ContentCorrect() { // === Pause 后元数据已保存 === + /// Pause 后元数据已保存 — Retry16 外壳(flaky 根治,AGENTS 规范) + /// CI 高压下 ThreadPool 调度可饿死 GET 发出链超过单轮 10s 信号窗(run 39656665694 实证), + /// 每轮全新 session/handler 最多 16 次,任一轮走通即过;全部超时才判红。 [Fact] - public async Task Pause_PersistsMetadata() { + [Trait("Category", "Flaky")] + public async Task Pause_PersistsMetadata_Retry16Times() { + for (var attempt = 0; attempt < 16; attempt++) { + if (await TryPausePersistsMetadataOnceAsync()) { + return; + } + } + throw new Xunit.Sdk.XunitException("16次重试均失败: GET 信号窗内未能进入 Downloading 并 Pause(调度饿死超窗)"); + } + + /// 单轮 Pause 元数据验证 — false = 本轮超时(调度饿死,非断言失败) + private static async Task TryPausePersistsMetadataOnceAsync() { var getStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var handler = CreateDelayedHandler(1024, delayMs: 2000, etag: "\"etag1\"", getStarted); await using var fs = new InMemoryFileSystem(); await using var downloader = new RangeDownloader(new TestHttpClientProvider(new HttpClient(handler)), fs); await using var session = downloader.StartDownload(Url, FilePath, new DownloadOptions { MaxThreads = 1 }); - await WaitDownloadingAndPauseAsync(session, getStarted); + try { + await WaitDownloadingAndPauseAsync(session, getStarted); + } catch (TimeoutException) { + return false; + } var metaPath = MetadataStore.GetMetadataPath(FilePath); fs.FileExists(metaPath).Should().BeTrue("Pause 后应持久化 .meta.json 供 Resume 恢复"); + return true; } // === 辅助 === diff --git a/native_gui/apps/NativeGui.Host/NativeGui.Host.csproj b/native_gui/apps/NativeGui.Host/NativeGui.Host.csproj index 898c5da2a4..033155facc 100644 --- a/native_gui/apps/NativeGui.Host/NativeGui.Host.csproj +++ b/native_gui/apps/NativeGui.Host/NativeGui.Host.csproj @@ -51,7 +51,7 @@ - + diff --git a/native_gui/apps/NativeGui.Host/hosting/JccChatSession.cs b/native_gui/apps/NativeGui.Host/hosting/JccChatSession.cs index 70e44909b3..16a416c526 100644 --- a/native_gui/apps/NativeGui.Host/hosting/JccChatSession.cs +++ b/native_gui/apps/NativeGui.Host/hosting/JccChatSession.cs @@ -308,6 +308,21 @@ public void SwitchSession(string sessionId) { ctxMgr?.SwitchSession(sessionId); } + /// + /// 轮内注入(B11)— 投递到引擎的 steer 邮箱,由查询循环在下一 step 开头落成用户消息。 + /// 邮箱是单例(按会话寻址),因此投递与会话切换顺序无关。 + /// + /// 目标会话 + /// 用户输入文本 + /// 投递成功 true;引擎未装配邮箱返回 false(调用方据此回 session/steer-unavailable) + public bool TrySteerInput(string sessionId, string text) { + if (_services.GetService() is not { } mailbox) { + return false; + } + mailbox.Enqueue(sessionId, text); + return true; + } + /// /// 从持久化历史灌入底层对话上下文 — GUI 新进程 StateService 内存为空, /// 先 ClearMessagesAsync 清空当前桶,再逐条灌入历史消息到 IChatContextManager。 diff --git a/native_gui/src/NativeGui.Core/GlobalUsings.cs b/native_gui/src/NativeGui.Core/GlobalUsings.cs index 0c2dd2eaa1..8baca245e4 100644 --- a/native_gui/src/NativeGui.Core/GlobalUsings.cs +++ b/native_gui/src/NativeGui.Core/GlobalUsings.cs @@ -50,6 +50,7 @@ global using System.Text.Json; global using System.Text.Json.Serialization; global using System.Threading; +global using System.Threading.Channels; global using System.Threading.Tasks; // webview 协议面(BridgeProtocol/PermissionPromptBroker/WebChatFlow,ADR 0145 随迁 Core) global using JoinCode.Gui.WebView; diff --git a/native_gui/src/NativeGui.Core/hosting/IJccChatSession.cs b/native_gui/src/NativeGui.Core/hosting/IJccChatSession.cs index 87b8676ba5..c724b573c2 100644 --- a/native_gui/src/NativeGui.Core/hosting/IJccChatSession.cs +++ b/native_gui/src/NativeGui.Core/hosting/IJccChatSession.cs @@ -26,6 +26,16 @@ public interface IJccChatSession : IAsyncDisposable { /// 以事件流方式发送消息(含工具调用迭代),是聊天界面主通道 IAsyncEnumerable StreamAsync(string message, CancellationToken cancellationToken = default); + /// + /// 轮内注入(steer)— 把用户输入投递给**当前轮的下一个 step**(不排队、不开新轮)。 + /// 对齐 DSH agent.steer(input):引擎在该步开头把文本落成用户消息,并在流里回 + /// 事件。默认实现返回 false(引擎未装配注入通道)。 + /// + /// 目标会话(须与正在运行的轮次同一会话) + /// 用户输入文本 + /// 投递成功返回 true;引擎未装配注入邮箱返回 false + bool TrySteerInput(string sessionId, string text) => false; + /// /// 执行斜杠命令(G1 对齐 TUI):解析 → CmdMap 路由(斜杠命令→MCP 工具)→ 捕获输出。 /// 返回命令输出文本;未知命令返回带提示的错误文案。非 / 前缀输入行为未定义。 diff --git a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs index 58be0ee84f..157718fb74 100644 --- a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs +++ b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs @@ -167,6 +167,27 @@ public Task ExecuteSlashCommandAsync(string input, CancellationToken can /// 轮次闸门(测试接缝):StreamAsync 开头等待此委托返回;null=立即生成。用于测试保持 busy 占用 internal Func? TurnGate { get; set; } + /// + /// 轮内注入接收器(测试接缝):null=引擎未装配注入通道 → 返回 false + /// (宿主据此回 session/steer-unavailable);设置后返回其返回值并记录 (sessionId, text)。 + /// + internal Func? SteerSink { get; set; } + + /// 最近一次轮内注入(测试追踪字段) + internal (string SessionId, string Text)? LastSteer { get; private set; } + + /// 思考块开关(测试接缝):false 时只产出正文,用于验证纯正文轮的块下标分配 + internal bool EmitThinking { get; set; } = true; + + /// + public bool TrySteerInput(string sessionId, string text) { + if (SteerSink is not { } sink) { + return false; + } + LastSteer = (sessionId, text); + return sink(sessionId, text); + } + /// 占位会话流式回显 — 产出思考/工具/正文示例事件供 UI 渲染验证 public async IAsyncEnumerable StreamAsync( string message, @@ -174,13 +195,15 @@ public async IAsyncEnumerable StreamAsync( if (TurnGate is { } gate) { await gate(cancellationToken); } - foreach (var ch in "让我先分析一下你的问题。") { - cancellationToken.ThrowIfCancellationRequested(); + if (EmitThinking) { + foreach (var ch in "让我先分析一下你的问题。") { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return ChatStreamEvent.Thinking(ch.ToString()); + } await Task.Yield(); - yield return ChatStreamEvent.Thinking(ch.ToString()); + yield return ChatStreamEvent.Thinking("\n[推理过程] 用户询问:"); } - await Task.Yield(); - yield return ChatStreamEvent.Thinking("\n[推理过程] 用户询问:"); // 后台 agent 动作 1:搜索 yield return ChatStreamEvent.ToolStart("web_search", "call_search_01", "{\"query\":\"" + message + "\"}"); @@ -196,10 +219,12 @@ public async IAsyncEnumerable StreamAsync( yield return ChatStreamEvent.ToolEnd("ReadFile", "读取 120 行,包含示例代码", "call_read_01"); // 思考结论 - foreach (var ch in "\n[结论] 综合搜索结果与代码分析,给出以下回答:") { - cancellationToken.ThrowIfCancellationRequested(); - await Task.Yield(); - yield return ChatStreamEvent.Thinking(ch.ToString()); + if (EmitThinking) { + foreach (var ch in "\n[结论] 综合搜索结果与代码分析,给出以下回答:") { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Yield(); + yield return ChatStreamEvent.Thinking(ch.ToString()); + } } // 正式回复内容(Markdown 示例,供 UI Markdown 渲染目视验证) diff --git a/native_gui/src/NativeGui.Core/properties/AssemblyInfo.cs b/native_gui/src/NativeGui.Core/properties/AssemblyInfo.cs index 7691339ea4..6694215ba7 100644 --- a/native_gui/src/NativeGui.Core/properties/AssemblyInfo.cs +++ b/native_gui/src/NativeGui.Core/properties/AssemblyInfo.cs @@ -1,4 +1,5 @@ // Core internal 面向宿主与测试开放(ADR 0145)。 // 注:assembly attribute 必须置于全局级别;using 统一在 GlobalUsings.cs(System.Runtime.CompilerServices 已声明)。 [assembly: InternalsVisibleTo("NativeGui.Host")] -[assembly: InternalsVisibleTo("JoinCode.Gui.Tests")] +[assembly: InternalsVisibleTo("NativeGui.Core.Tests")] +[assembly: InternalsVisibleTo("NativeGui.Host.Tests")] diff --git a/native_gui/src/NativeGui.Core/web_host/DshApprovalBroker.cs b/native_gui/src/NativeGui.Core/web_host/DshApprovalBroker.cs index af8e5fb1d4..2ed2fa6385 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshApprovalBroker.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshApprovalBroker.cs @@ -9,8 +9,23 @@ namespace JoinCode.Gui.WebHost; /// 决策词汇:前端 allowed-once → Allow;rejected/其他 → Deny(前端无 always-allow 概念)。 /// internal sealed class DshApprovalBroker { - /// 一次挂起中的确认 - private sealed record Pending(TaskCompletionSource Decision); + /// 一次挂起中的确认(eventId 用于回帖结算,approvalId 用于会话审计对) + private sealed record Pending( + string EventId, + string? SessionId, + string ApprovalId, + TaskCompletionSource Completion); + + /// 一次确认的结算:引擎决策 + 会话审计结局词汇(allowed-once/rejected/cancelled/unavailable) + private readonly record struct ApprovalSettlement(Hosting.PermissionConfirmationDecision Decision, string Outcome); + + /// 前端未连接/已断开(fail-closed,对齐 DSH「无应答者 → unavailable」) + private static readonly ApprovalSettlement DeniedUnavailable = + new(Hosting.PermissionConfirmationDecision.Deny, "unavailable"); + + /// 轮次结束时仍挂起(等价 DSH 的 signal aborted → cancelled) + private static readonly ApprovalSettlement DeniedCancelled = + new(Hosting.PermissionConfirmationDecision.Deny, "cancelled"); private readonly System.Collections.Concurrent.ConcurrentDictionary _pending = new(StringComparer.Ordinal); @@ -20,6 +35,13 @@ private sealed record Pending(TaskCompletionSourcewaterfall 帧的目标会话(轮次执行时由 DshTurnRunner 设置;缺省用 host 常量) private volatile string? _agentId; + /// + /// 会话审计事件落库委托(宿主注入:sessionId/type/data → 会话事件日志);null = 不落审计。 + /// 对齐 DSH:审批必须在开放轮次内落 approval/asked + approval/decided 成对审计, + /// 否则历史回放会丢失审批记录(或留下崩溃残尾)。 + /// + internal Func? Audit { get; set; } + /// $events 流打开时挂接发送通道(连接级单例;旧连接被新连接替换) /// 帧值发送委托(宿主闭包绑定 streamId,保证路由到该逻辑流) /// 就绪帧下发的客户端标识 @@ -31,37 +53,107 @@ internal void Attach(Func send, string clientId) { /// 连接状态读写锁(volatile 元组不可用;读写互斥保护 _events 与 _pending 的结算一致性) private readonly object _gate = new(); + /// + /// 断流补写的审计写入屏障: 的审计是后台任务(释放路径禁阻塞,ADR 0093), + /// 这里累积它们的完成任务,由 在轮次收尾前排空 —— + /// 否则 approval/decided 可能排到 turn/end 之后,违反 DSH「审计对被开放轮次包住」。 + /// + private Task _detachedAudit = Task.CompletedTask; + /// $events 流关闭时摘除通道:挂起中的请求立即拒绝(不挂引擎轮次),后续请求直接拒绝 internal void Detach() { lock (_gate) { _events = null; } + var orphans = new List(); foreach (var (eventId, pending) in _pending) { - if (_pending.TryRemove(eventId, out _)) { - pending.Decision.TrySetResult(Hosting.PermissionConfirmationDecision.Deny); + if (_pending.TryRemove(eventId, out var removed)) { + orphans.Add(removed); } } + // 释放路径禁止阻塞:审计 decided 交后台补写,并把补写任务挂进屏障供轮次收尾排空(无前端可回放,仅日志卫生) + foreach (var orphan in orphans) { + var settle = SettleAsync(orphan, DeniedUnavailable); + lock (_gate) { _detachedAudit = Task.WhenAll(_detachedAudit, settle); } + } GuiDiag.Log("[DSH-APPROVAL] events stream detached: pending requests denied"); } /// 设置 waterfall 目标会话(DshTurnRunner 轮次开始时调用;前端按 agentId 路由到对应会话 scope) internal void SetAgent(string? sessionId) => _agentId = sessionId; + /// + /// 轮次收尾:清空 waterfall 目标会话,把该会话仍挂起的确认结算为 cancelled,并排空断流补写的审计。 + /// (对齐 DSH:signal aborted → cancelled;同时避免挂起请求在轮次结束后残留) + /// 必须在 Streams.EndTurn 之前调用,审计对才仍被本轮包住。 + /// + /// 轮次所属会话 + /// 结算与审计落库完成的任务 + internal async Task CloseTurnAsync(string sessionId) { + _agentId = null; + var orphans = new List(); + foreach (var (eventId, pending) in _pending) { + if (string.Equals(pending.SessionId, sessionId, StringComparison.Ordinal) && _pending.TryRemove(eventId, out var removed)) { + orphans.Add(removed); + } + } + foreach (var orphan in orphans) { + await SettleAsync(orphan, DeniedCancelled); + } + // 排空断流补写(含并发新到的):审计必须是发起调用者的责任,返回时保证 decided 已落库 + while (true) { + var barrier = DetachedAuditBarrier(); + await barrier; + if (!DetachedAuditReplaced(barrier)) { + break; + } + } + } + + /// 同步读取断流审计屏障(lock 收敛在同步方法内,规避 async 方法持锁,JCC4001) + private Task DetachedAuditBarrier() { + lock (_gate) { return _detachedAudit; } + } + + /// 同步判定屏障是否已被新到任务替换(同上:避免 async 方法持锁) + /// 先前读取到的屏障 + /// 是否已被替换 + private bool DetachedAuditReplaced(Task barrier) { + lock (_gate) { return !ReferenceEquals(_detachedAudit, barrier); } + } + /// /// 引擎确认点入口:构造 waterfall 帧发给前端并等待回帖。 /// $events 未连接 → 立即拒绝;request 载荷只含 toolName/reason/callId(前端硬校验禁含 agent/signal)。 + /// 开放轮次内先落 approval/asked 审计,结算时补 approval/decided。 /// /// 引擎确认请求 /// 前端决策(allowed-once=Allow;其余一律 Deny) internal async Task RequestAsync(Hosting.PermissionConfirmationRequest request) { + var sessionId = _agentId; + var approvalId = Guid.NewGuid().ToString("N"); + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var eventId = Guid.NewGuid().ToString("N"); + var pending = new Pending(eventId, sessionId, approvalId, completion); + + if (sessionId is not null) { + await AppendAuditAsync(sessionId, "approval/asked", new DshApprovalAskedData { + Id = approvalId, + ToolName = request.ToolName, + CallId = request.RequestId, + Reason = request.ConfirmationPrompt, + }); + } else { + GuiDiag.Log("[DSH-APPROVAL] no open turn: approval audit pair skipped"); + } + var send = CurrentSend(); if (send is null) { - return Hosting.PermissionConfirmationDecision.Deny; + return await SettleAsync(pending, DeniedUnavailable); } - var eventId = Guid.NewGuid().ToString("N"); var frame = new DshWaterfallFrame { Event = "approval/request", EventId = eventId, - AgentId = _agentId ?? "host", + AgentId = sessionId ?? "host", Request = JsonSerializer.SerializeToElement( new DshApprovalRequestBody { ToolName = request.ToolName, @@ -69,18 +161,53 @@ internal void Detach() { CallId = request.RequestId, }, DshJsonContext.Default.DshApprovalRequestBody), }; - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - _pending[eventId] = new Pending(completion); + _pending[eventId] = pending; try { await send(JsonSerializer.SerializeToElement(frame, DshJsonContext.Default.DshWaterfallFrame)); GuiDiag.Log($"[DSH-APPROVAL] waterfall sent event=approval/request id={eventId} tool={request.ToolName}"); } catch (Exception ex) { _pending.TryRemove(eventId, out _); GuiDiag.Log($"[DSH-APPROVAL] waterfall send failed: {ex.Message}"); - return Hosting.PermissionConfirmationDecision.Deny; + return await SettleAsync(pending, DeniedUnavailable); + } + + return (await completion.Task).Decision; + } + + /// + /// 结算一次挂起:赢家写 approval/decided 审计(只写一次)并唤醒等待方,返回引擎决策。 + /// 落在轮内的正常路径(前端回帖)由本方法闭合审计对;轮次已结束/断流的补写走同一入口。 + /// + /// 挂起中的确认 + /// 决策 + 审计结局词汇 + /// 引擎决策(重复结算返回首次结果) + private async Task SettleAsync(Pending pending, ApprovalSettlement settlement) { + if (pending.Completion.TrySetResult(settlement)) { + if (pending.SessionId is { } sessionId) { + await AppendAuditAsync(sessionId, "approval/decided", new DshApprovalDecidedData { + Id = pending.ApprovalId, + Outcome = settlement.Outcome, + }); + } + GuiDiag.Log($"[DSH-APPROVAL] decided id={pending.ApprovalId} outcome={settlement.Outcome} -> {settlement.Decision}"); } + return (await pending.Completion.Task).Decision; + } - return await completion.Task; + /// 落一条审批审计事件(委托未注入时静默跳过并记日志) + /// 会话 ID + /// 事件类型(approval/asked 或 approval/decided) + /// 审计载荷 + private async Task AppendAuditAsync(string sessionId, string type, T payload) { + if (Audit is not { } audit) { + GuiDiag.Log($"[DSH-APPROVAL] audit sink missing: {type} not persisted"); + return; + } + try { + await audit(sessionId, type, JsonSerializer.SerializeToElement(payload, DshJsonContext.Default.GetTypeInfo(typeof(T)))); + } catch (Exception ex) { + GuiDiag.Log($"[DSH-APPROVAL] audit append failed type={type}: {ex.Message}"); + } } /// 同步读取当前发送通道(lock 收敛在同步方法内,规避 async 方法持锁) @@ -89,7 +216,8 @@ internal void Detach() { } /// - /// 前端回帖结算:kind=result 且值为 allowed-once → Allow;next/rejected/其他值 → Deny。 + /// 前端回帖结算:kind=result 且值为 allowed-once → Allow(审计 allowed-once);kind=result 的其他值 → + /// 用户显式拒绝(审计 rejected);kind=next/rejected/缺失 → 无有效应答者(审计 unavailable,fail-closed)。 /// clientId 与当前 $events 连接不符(前端重连后的旧代际回帖)或未知 eventId(已结算/从未发起)返回 false(幂等,不抛错)。 /// /// 回帖声称的客户端标识(校验与当前连接一致) @@ -106,14 +234,23 @@ internal Task SettleAsync(string clientId, string eventId, DshEventOutcome return Task.FromResult(false); } - var decision = Hosting.PermissionConfirmationDecision.Deny; - if (outcome is { Kind: "result" } && outcome.Value is { ValueKind: JsonValueKind.String } value - && value.GetString() == "allowed-once") { - decision = Hosting.PermissionConfirmationDecision.Allow; + return SettleAndReportAsync(pending, MapSettlement(outcome)); + } + + /// 回帖载荷 → 审计结局词汇(唯一放行值是 allowed-once) + private static ApprovalSettlement MapSettlement(DshEventOutcome? outcome) { + if (outcome is not { Kind: "result" } || outcome.Value is not { ValueKind: JsonValueKind.String } value) { + return DeniedUnavailable; } - pending.Decision.TrySetResult(decision); - GuiDiag.Log($"[DSH-APPROVAL] settled id={eventId} kind={outcome?.Kind} value={outcome?.Value} -> {decision}"); - return Task.FromResult(true); + return value.GetString() == "allowed-once" + ? new ApprovalSettlement(Hosting.PermissionConfirmationDecision.Allow, "allowed-once") + : new ApprovalSettlement(Hosting.PermissionConfirmationDecision.Deny, "rejected"); + } + + /// 结算并忽略决策(RPC 回帖只需幂等确认,决策由引擎侧继续消费) + private async Task SettleAndReportAsync(Pending pending, ApprovalSettlement settlement) { + await SettleAsync(pending, settlement); + return true; } } diff --git a/native_gui/src/NativeGui.Core/web_host/DshGoalStore.cs b/native_gui/src/NativeGui.Core/web_host/DshGoalStore.cs new file mode 100644 index 0000000000..c2b091e07e --- /dev/null +++ b/native_gui/src/NativeGui.Core/web_host/DshGoalStore.cs @@ -0,0 +1,209 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// DSH 目标存储(TASK058 P2-B5):每会话一个目标(GoalBar 语义),七方法状态机 + revision CAS + +/// roundsStarted 轮次计数(DshTurnRunner 受理钩子递增)+ activation 翻转事件(GoalChanged → +/// 宿主桥 $events waterfall 帧给前端 goal/activation-changed)。持久化 ~/.jcc/dsh_goals.json。 +/// v1 宿主数据闭环:目标 CRUD/状态机/计数/事件全真;引擎节点图 goal 系统联动(每 N 轮自动 +/// evaluate)属深化项,另行立项对齐。 +/// +internal sealed class DshGoalStore(IFileSystem fs, string? filePath = null) { + private readonly string _filePath = filePath + ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_goals.json"); + private readonly object _gate = new(); + private Dictionary _goals = new(StringComparer.Ordinal); + + /// activation 翻转事件(sessionId + 目标快照)— 宿主桥 $events 帧用 + internal event Action? GoalChanged; + + private bool _loaded; + + /// 读会话目标(无目标返回 null) + internal DshGoal? Get(string sessionId) { + lock (_gate) { + EnsureLoaded(); + return _goals.TryGetValue(sessionId, out var doc) ? Project(doc) : null; + } + } + + /// 创建目标(armed/active,revision=1);已存在目标返回 conflict 引导 + internal (DshGoalRef? Ref, DshRpcResult? Error) Create(string sessionId, string objective, int? maxGoalRounds) { + if (string.IsNullOrWhiteSpace(objective)) { + return (null, DshRpcResults.Fail("goals/rejected", "goals/create 需要非空 objective;在目标栏输入目标后重试")); + } + lock (_gate) { + EnsureLoaded(); + if (_goals.ContainsKey(sessionId)) { + var current = _goals[sessionId].Revision; + return (null, DshRpcResults.Fail("goals/conflict", + $"会话 {sessionId} 已有目标(revision={current});先 clear 再创建新目标")); + } + var doc = new DshGoalDocument { + Id = Guid.NewGuid().ToString("N"), + Revision = 1, + Objective = objective, + MaxGoalRounds = maxGoalRounds ?? DefaultMaxGoalRounds, + Phase = "active", + Activation = "armed", + RoundsStarted = 0, + CreatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + _goals[sessionId] = doc; + Persist(); + return (new DshGoalRef { Id = doc.Id, Revision = doc.Revision }, null); + } + } + + /// 编辑目标(CAS:expectedRevision 不匹配返回 conflict);objective/maxGoalRounds 可改 + internal (DshGoal? Goal, DshRpcResult? Error) Edit(string sessionId, DshGoalRef reference, string? objective, int? maxGoalRounds) { + lock (_gate) { + EnsureLoaded(); + if (!_goals.TryGetValue(sessionId, out var doc) || doc.Id != reference.Id) { + return (null, DshRpcResults.Fail("goals/not-found", $"会话 {sessionId} 无目标 {reference.Id};先 goals/get 确认再操作")); + } + if (doc.Revision != reference.Revision) { + return (null, DshRpcResults.Fail("goals/conflict", + $"目标版本 {reference.Revision} 已过期(当前 {doc.Revision});刷新目标栏后用新 revision 重试")); + } + if (doc.Phase == "complete") { + return (null, DshRpcResults.Fail("goals/rejected", "目标已完成,不可编辑;clear 后创建新目标")); + } + if (!string.IsNullOrWhiteSpace(objective)) { + doc = doc with { Objective = objective }; + } + if (maxGoalRounds is { } rounds) { + doc = doc with { MaxGoalRounds = rounds }; + } + doc = doc with { Revision = doc.Revision + 1, UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + _goals[sessionId] = doc; + Persist(); + return (Project(doc), null); + } + } + + /// 状态迁移(pause→disarmed/resume→armed/complete→disarmed;均 CAS)— activation 翻转触发 GoalChanged + private (DshGoal? Goal, DshRpcResult? Error) Transition(string sessionId, DshGoalRef reference, string phase, string activation) { + lock (_gate) { + EnsureLoaded(); + if (!_goals.TryGetValue(sessionId, out var doc) || doc.Id != reference.Id) { + return (null, DshRpcResults.Fail("goals/not-found", $"会话 {sessionId} 无目标 {reference.Id};先 goals/get 确认再操作")); + } + if (doc.Revision != reference.Revision) { + return (null, DshRpcResults.Fail("goals/conflict", + $"目标版本 {reference.Revision} 已过期(当前 {doc.Revision});刷新目标栏后用新 revision 重试")); + } + if (doc.Phase == "complete") { + return (null, DshRpcResults.Fail("goals/rejected", "目标已完成,状态不可再迁移;clear 后创建新目标")); + } + var activationFlipped = doc.Activation != activation; + doc = doc with { + Phase = phase, + Activation = activation, + Revision = doc.Revision + 1, + UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + _goals[sessionId] = doc; + Persist(); + if (activationFlipped) { + GoalChanged?.Invoke(sessionId, doc); + } + return (Project(doc), null); + } + } + + /// 暂停(active→paused,armed→disarmed) + internal (DshGoal? Goal, DshRpcResult? Error) Pause(string sessionId, DshGoalRef reference) => + Transition(sessionId, reference, "paused", "disarmed"); + + /// 恢复(paused→active,disarmed→armed) + internal (DshGoal? Goal, DshRpcResult? Error) Resume(string sessionId, DshGoalRef reference) => + Transition(sessionId, reference, "active", "armed"); + + /// 完成(→complete,disarmed) + internal (DshGoal? Goal, DshRpcResult? Error) Complete(string sessionId, DshGoalRef reference) => + Transition(sessionId, reference, "complete", "disarmed"); + + /// 清除(CAS;返回被清目标的 id/revision) + internal (DshGoalRef? Ref, DshRpcResult? Error) Clear(string sessionId, DshGoalRef reference) { + lock (_gate) { + EnsureLoaded(); + if (!_goals.TryGetValue(sessionId, out var doc) || doc.Id != reference.Id) { + return (null, DshRpcResults.Fail("goals/not-found", $"会话 {sessionId} 无目标 {reference.Id};先 goals/get 确认再操作")); + } + if (doc.Revision != reference.Revision) { + return (null, DshRpcResults.Fail("goals/conflict", + $"目标版本 {reference.Revision} 已过期(当前 {doc.Revision});刷新目标栏后用新 revision 重试")); + } + _goals.Remove(sessionId); + Persist(); + GoalChanged?.Invoke(sessionId, doc); + return (new DshGoalRef { Id = doc.Id, Revision = doc.Revision + 1 }, null); + } + } + + /// 轮次计数(DshTurnRunner 受理钩子;无目标/未武装 no-op) + internal void IncrementRounds(string sessionId) { + lock (_gate) { + EnsureLoaded(); + if (!_goals.TryGetValue(sessionId, out var doc) || doc.Activation != "armed") { + return; + } + _goals[sessionId] = doc with { + RoundsStarted = doc.RoundsStarted + 1, + UpdatedAt = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + }; + Persist(); + } + } + + /// 投影落盘文档 → wire 形状(时间毫秒) + private static DshGoal Project(DshGoalDocument doc) => new() { + Id = doc.Id, + Revision = doc.Revision, + Objective = doc.Objective, + Phase = doc.Phase, + Activation = doc.Activation, + RoundsStarted = doc.RoundsStarted, + MaxGoalRounds = doc.MaxGoalRounds, + CreatedAt = doc.CreatedAt, + UpdatedAt = doc.UpdatedAt, + }; + + /// 惰性加载(首次访问读盘;损坏/缺失退化为空表) + private void EnsureLoaded() { + if (_loaded) { + return; + } + _loaded = true; + try { + if (!fs.FileExists(_filePath)) { + return; + } + var json = fs.ReadAllTextAsync(_filePath, CancellationToken.None).GetAwaiter().GetResult(); + var document = RelaxedJsonSerializer.Deserialize(json, DshJsonContext.Default.DshGoalsDocument); + if (document?.Goals is { Count: > 0 } goals) { + _goals = new Dictionary(goals, StringComparer.Ordinal); + } + } catch (Exception ex) { + GuiDiag.Log($"[DSH-GOALS] load failed: {ex.Message}"); + } + } + + /// 全量落盘(KB 级低频写;失败只记日志不回滚) + private void Persist() { + try { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !fs.DirectoryExists(directory)) { + fs.CreateDirectory(directory); + } + var document = new DshGoalsDocument { Goals = _goals }; + _ = fs.WriteAllTextAsync(_filePath, RelaxedJsonSerializer.Serialize(document, DshJsonContext.Default), CancellationToken.None); + } catch (Exception ex) { + GuiDiag.Log($"[DSH-GOALS] persist failed: {ex.Message}"); + } + } + + /// 默认轮次上限(前端 zod 可选;v1 常量 10) + private const int DefaultMaxGoalRounds = 10; +} diff --git a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs index 2e4ec09796..4d45c05c9b 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs @@ -17,9 +17,12 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul Sessions = new DshSessionStore(Journal); Models = new DshModelCatalogSource(); Streams = new DshSessionStreamHub(Journal); - Approvals = new DshApprovalBroker(); + Approvals = new DshApprovalBroker { + Audit = (sessionId, type, payload) => Streams.PublishElementAsync(sessionId, type, payload), + }; WorkspaceFiles = new DshWorkspaceFilesService(Sessions, fileSystem); Attachments = new DshAttachmentStore(fileSystem); + Goals = new DshGoalStore(fileSystem); } /// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误) @@ -55,4 +58,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul /// 附件存储(fileUploads/upload 与 session/attachment,TASK058 P2-B2) internal DshAttachmentStore Attachments { get; } + + /// 目标存储(goals 七方法 + activation-changed 事件,TASK058 P2-B5) + internal DshGoalStore Goals { get; } } diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs index e279f5c923..e7d51a7e9e 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs @@ -74,6 +74,14 @@ internal DshRpcDispatcher(DshHostServices services) { ["session/attachment"] = SessionAttachment, // 队列编辑(TASK058 P2-B3;steer=引擎无注入点诚实拒绝) ["session/updateQueue"] = SessionUpdateQueue, + // 目标七方法(TASK058 P2-B5;引擎节点图 goal 联动属深化项) + ["goals/get"] = Sync(GoalsGet), + ["goals/create"] = Sync(GoalsCreate), + ["goals/edit"] = Sync(GoalsEdit), + ["goals/pause"] = Sync(GoalsPause), + ["goals/resume"] = Sync(GoalsResume), + ["goals/clear"] = Sync(GoalsClear), + ["goals/complete"] = Sync(GoalsComplete), }.ToFrozenDictionary(StringComparer.Ordinal); } @@ -414,13 +422,87 @@ private async Task FileUploadsUpload(JsonElement args, Cancellatio return await _services.Attachments.UploadAsync(request.AgentId, request.Request.Data, request.Request.Name, cancellationToken); } + /// goals/get:会话目标(无目标 null) + private DshRpcResult GoalsGet(JsonElement args) { + var request = args.Deserialize(DshJsonContext.Default.DshGoalsRefArgs); + if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) { + return Fail("goals/rejected", $"goals/get 需要 {{sessionId}};收到 args={Describe(args)}"); + } + var goal = _services.Goals.Get(request.SessionId); + return goal is null + ? OkNull() + : Success(JsonSerializer.SerializeToElement(goal, DshJsonContext.Default.DshGoal)); + } + + /// goals/create:创建目标(已存在 conflict 引导) + private DshRpcResult GoalsCreate(JsonElement args) { + var request = args.Deserialize(DshJsonContext.Default.DshGoalsCreateArgs); + if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) { + return Fail("goals/rejected", $"goals/create 需要 {{sessionId, objective, maxGoalRounds?}};收到 args={Describe(args)}"); + } + var (reference, error) = _services.Goals.Create(request.SessionId, request.Objective, request.MaxGoalRounds); + if (error is not null || reference is null) { + return error ?? Fail("goals/rejected", "创建失败"); + } + return Success(JsonSerializer.SerializeToElement(new DshGoalsCreateResult { Ref = reference }, + DshJsonContext.Default.DshGoalsCreateResult)); + } + + /// goals/edit:CAS 编辑(objective/maxGoalRounds) + private DshRpcResult GoalsEdit(JsonElement args) { + var request = args.Deserialize(DshJsonContext.Default.DshGoalsEditArgs); + if (request is null || request.SessionId is not { Length: > 0 } || request.Ref is null || request.Changes is null) { + return Fail("goals/rejected", $"goals/edit 需要 {{sessionId, ref, changes}};收到 args={Describe(args)}"); + } + var (goal, error) = _services.Goals.Edit(request.SessionId, request.Ref, request.Changes.Objective, request.Changes.MaxGoalRounds); + return error is not null + ? error + : Success(JsonSerializer.SerializeToElement(goal, DshJsonContext.Default.DshGoal)); + } + + /// 状态迁移路由共用(ref 校验 + 委托) + private DshRpcResult GoalsTransition(JsonElement args, Func action) { + var request = args.Deserialize(DshJsonContext.Default.DshGoalsRefArgs); + if (request is null || request.SessionId is not { Length: > 0 } || request.Ref is null) { + return Fail("goals/rejected", $"goals 方法需要 {{sessionId, ref:{{id, revision}}}};收到 args={Describe(args)}"); + } + var (goal, error) = action(request.SessionId, request.Ref); + return error is not null + ? error + : Success(JsonSerializer.SerializeToElement(goal, DshJsonContext.Default.DshGoal)); + } + + /// goals/pause + private DshRpcResult GoalsPause(JsonElement args) => GoalsTransition(args, _services.Goals.Pause); + + /// goals/resume + private DshRpcResult GoalsResume(JsonElement args) => GoalsTransition(args, _services.Goals.Resume); + + /// goals/complete + private DshRpcResult GoalsComplete(JsonElement args) => GoalsTransition(args, _services.Goals.Complete); + + /// goals/clear:清除(返回被清引用) + private DshRpcResult GoalsClear(JsonElement args) { + var request = args.Deserialize(DshJsonContext.Default.DshGoalsRefArgs); + if (request is null || request.SessionId is not { Length: > 0 } || request.Ref is null) { + return Fail("goals/rejected", $"goals/clear 需要 {{sessionId, ref:{{id, revision}}}};收到 args={Describe(args)}"); + } + var (reference, error) = _services.Goals.Clear(request.SessionId, request.Ref); + if (error is not null || reference is null) { + return error ?? Fail("goals/rejected", "清除失败"); + } + return Success(JsonSerializer.SerializeToElement(new DshGoalsClearResult { Ref = reference }, + DshJsonContext.Default.DshGoalsClearResult)); + } + /// 会话队列快照透传(updateQueue 响应与测试消费;internal 接缝) internal IReadOnlyList ListQueue(string sessionId) => _turns.ListQueue(sessionId); /// - /// session/updateQueue:排队项编辑(TASK058 P2-B3)。edit=重写文本,remove=移除; - /// steer 返回 session/steer-unavailable(引擎 StreamAsync 一轮独占无轮内注入点 — 诚实引导, - /// 消息保持排队将在下一轮执行)。行不在队列/已被 claim 返回 session/queue-item-not-found。 + /// session/updateQueue:排队项编辑(TASK058 P2-B3 + B11)。edit=重写文本,remove=移除, + /// steer=把排队项**升级为轮内注入**(出队 → 投递引擎邮箱,DSH `action.kind==='steer'` 语义)。 + /// 项不存在返回 session/queue-item-not-found;轮次未运行或引擎未装配邮箱返回 + /// session/steer-unavailable(此时该消息**仍留在队列**,不会丢)。 /// private Task SessionUpdateQueue(JsonElement args, CancellationToken cancellationToken) { var request = args.Deserialize(DshJsonContext.Default.DshUpdateQueueArgs); @@ -430,8 +512,7 @@ private Task SessionUpdateQueue(JsonElement args, CancellationToke $"session/updateQueue 需要 {{sessionId, itemId, action:{{kind, content?}}}};收到 args={Describe(args)}")); } if (string.Equals(request.Action.Kind, "steer", StringComparison.Ordinal)) { - return Task.FromResult(Fail("session/steer-unavailable", - "引擎暂不支持轮内注入(steer);该消息保持排队,将在当前轮结束后的下一轮执行")); + return Task.FromResult(SteerQueueItem(request.SessionId, request.ItemId)); } if (string.Equals(request.Action.Kind, "remove", StringComparison.Ordinal)) { return Task.FromResult(_turns.TryRemoveQueueItem(request.SessionId, request.ItemId) @@ -453,6 +534,33 @@ private Task SessionUpdateQueue(JsonElement args, CancellationToke $"updateQueue 的 action.kind 必须是 edit/remove/steer 之一;收到 {request.Action.Kind}")); } + /// + /// 把排队项升级为轮内注入:先确认项还在、轮次在跑、引擎能收,再出队并投递。 + /// 顺序保证失败路径**不动队列**(消息不丢,前端可继续 edit/remove/等下一轮)。 + /// + /// 会话 ID + /// 排队项句柄 + private DshRpcResult SteerQueueItem(string sessionId, string itemId) { + if (_services.Engine is not { } engine) { + return Fail("session/not-ready", "引擎仍在组装中,稍后重试;若长时间未就绪查看 dumps/startup_timing.log"); + } + var item = _turns.ListQueue(sessionId).FirstOrDefault(entry => string.Equals(entry.ItemId, itemId, StringComparison.Ordinal)); + if (item is null) { + return Fail("session/queue-item-not-found", + $"队列项 {itemId} 不在队列中(可能已被下一轮 claim);刷新队列后重试"); + } + if (_services.Streams.RunningTurn(sessionId) == 0) { + return Fail("session/steer-unavailable", + $"会话 {sessionId} 当前没有运行中的轮次,轮内注入无处投放;该消息仍在队列,可用 remove 取消或等下一轮自动执行"); + } + if (!engine.TrySteerInput(sessionId, item.Text)) { + return Fail("session/steer-unavailable", + $"会话 {sessionId} 的引擎未装配轮内注入邮箱(ISteerInputMailbox);请重启宿主。该消息仍在队列,将在本轮结束后顺序执行"); + } + _turns.TryRemoveQueueItem(sessionId, itemId); + return Ok(new DshQueueAcceptedResult()); + } + /// session/attachment:读附件(元数据 + base64 数据,前端聊天内图片渲染) private async Task SessionAttachment(JsonElement args, CancellationToken cancellationToken) { var request = args.Deserialize(DshJsonContext.Default.DshSessionAttachmentArgs); diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs index 7e3f167c71..0824f7599a 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs @@ -141,6 +141,29 @@ internal sealed class DshEventErrorBody { /// $events/result 回帖响应(前端只看 ok,无业务载荷) internal sealed class DshEventsResultValue; +/// +/// 会话日志的审批审计事件 approval/asked(对齐 DSH dsh-user-approval:审批请求必须先落审计, +/// 且与 成对、被 turn/start…turn/end 包住,否则回放时是崩溃残尾)。 +/// +internal sealed class DshApprovalAskedData { + /// 审批请求 ID(与 waterfall 帧的 eventId 无关,审计对内部自洽) + public string Id { get; set; } = ""; + /// 请求审批的工具名(不可为空) + public string ToolName { get; set; } = ""; + /// 关联的工具调用 ID(可选) + public string? CallId { get; set; } + /// 人类可读原因(可选) + public string? Reason { get; set; } +} + +/// 会话日志的审批审计事件 approval/decided(词汇表 allowed-once/rejected/cancelled/unavailable) +internal sealed class DshApprovalDecidedData { + /// 与 approval/asked 相同的审批请求 ID + public string Id { get; set; } = ""; + /// 判定结果(allowed-once 是唯一放行值) + public string Outcome { get; set; } = ""; +} + /// fileUploads/upload 参数(前端 lookup 字段 agentId 即会话/代理 ID;request 包裹 data+name) internal sealed class DshFileUploadsUploadArgs { /// 目标会话/代理 @@ -306,6 +329,156 @@ internal sealed class DshWorkspaceFilesReadBytesResult { public string Version { get; set; } = ""; } +/// goals/get 响应(无目标时 null) +internal sealed class DshGoal { + /// 目标 ID(GUID) + public string Id { get; set; } = ""; + /// 版本(每次变更 +1,CAS 用) + public int Revision { get; set; } + /// 目标描述 + public string Objective { get; set; } = ""; + /// 阶段(active/paused/complete) + public string Phase { get; set; } = "active"; + /// 武装状态(armed=驱动轮次;disarmed=暂停驱动) + public string Activation { get; set; } = "armed"; + /// 已启动轮次(prompt 受理递增) + public int RoundsStarted { get; set; } + /// 轮次上限 + public int MaxGoalRounds { get; set; } + /// 创建时间(Unix 毫秒) + public long CreatedAt { get; set; } + /// 更新时间(Unix 毫秒) + public long UpdatedAt { get; set; } +} + +/// 目标引用(CAS 句柄:id + 期望 revision) +internal sealed class DshGoalRef { + /// 目标 ID + public string Id { get; set; } = ""; + /// 期望版本(须与当前一致否则 conflict) + public int Revision { get; set; } +} + +/// goals/get|pause|resume|complete 共用参数(ref 由各方法携带) +internal sealed class DshGoalsRefArgs { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 目标引用 + public DshGoalRef? Ref { get; set; } +} + +/// goals/create 参数 +internal sealed class DshGoalsCreateArgs { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 目标描述 + public string Objective { get; set; } = ""; + /// 轮次上限(可选,默认 10) + public int? MaxGoalRounds { get; set; } +} + +/// goals/create 响应 +internal sealed class DshGoalsCreateResult { + /// 新目标引用 + public DshGoalRef Ref { get; set; } = new(); +} + +/// goals/edit 参数 +internal sealed class DshGoalsEditArgs { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 目标引用(CAS) + public DshGoalRef? Ref { get; set; } + /// 变更内容 + public DshGoalChanges? Changes { get; set; } +} + +/// 目标变更内容(均可选) +internal sealed class DshGoalChanges { + /// 新目标描述 + public string? Objective { get; set; } + /// 新轮次上限 + public int? MaxGoalRounds { get; set; } +} + +/// goals/clear 响应 +internal sealed class DshGoalsClearResult { + /// 被清目标引用(revision 为清算后值) + public DshGoalRef Ref { get; set; } = new(); +} + +/// 目标落盘文档(~/.jcc/dsh_goals.json 条目) +internal sealed record DshGoalDocument { + /// 目标 ID + public string Id { get; init; } = ""; + /// 版本 + public int Revision { get; init; } + /// 目标描述 + public string Objective { get; init; } = ""; + /// 阶段 + public string Phase { get; init; } = "active"; + /// 武装状态 + public string Activation { get; init; } = "armed"; + /// 已启动轮次 + public int RoundsStarted { get; init; } + /// 轮次上限 + public int MaxGoalRounds { get; init; } + /// 创建时间(Unix 毫秒) + public long CreatedAt { get; init; } + /// 更新时间(Unix 毫秒) + public long UpdatedAt { get; init; } +} + +/// goal/activation-changed 事件载荷(前端 $on 监听) +internal sealed class DshGoalActivationChanged { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 目标状态摘要 + public DshGoalActivation Goal { get; set; } = new(); +} + +/// 目标激活状态摘要 +internal sealed class DshGoalActivation { + /// 目标 ID + public string Id { get; set; } = ""; + /// 版本 + public int Revision { get; set; } + /// 武装状态(armed/disarmed) + public string Activation { get; set; } = ""; +} + +/// dsh_goals.json 根文档 +internal sealed class DshGoalsDocument { + /// 会话 → 目标 + public Dictionary Goals { get; set; } = new(StringComparer.Ordinal); +} + +/// workspaceFiles/changes 就绪帧(kind=ready — 无附加载荷) +internal sealed class DshChangeReady { + /// 帧类型(ready) + public string Kind => "ready"; +} + +/// workspaceFiles/changes 变更帧(kind=change;ready 帧由宿主直接发) +internal sealed class DshFileChangeFrame { + /// 帧类型(change) + public string Kind => "change"; + /// 变更详情 + public DshFileChange Change { get; set; } = new(); +} + +/// 单条文件变更(version 与 absent 互斥 — 删除无版本) +internal sealed class DshFileChange { + /// 绝对路径 + public string AbsolutePath { get; set; } = ""; + /// 新版本(size-mtime 合成;删除时省略) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Version { get; set; } + /// 是否已删除(删除事件为 true) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? Absent { get; set; } +} + /// workspaceFiles/stat 响应 internal sealed class DshWorkspaceFilesStatResult { /// 绝对路径 @@ -964,10 +1137,104 @@ internal sealed class DshWorkflowRunEndData { public string StopReason { get; set; } = ""; } -/// 助手流基线(revision=0 表示无进行中的尝试) +/// +/// 助手流基线(ADR 0147):重连时前端据此重建「进行中尝试」已收到的文本, +/// revision=0 表示从未有过帧;有 时后续帧必须从 revision+1 续上。 +/// internal sealed class DshAssistantStreamBaseline { - /// 流版本 + /// 流版本(单调递增,跨轮次不重置) + public int Revision { get; set; } + /// 进行中的尝试(空闲时不下发该键) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DshAssistantStreamAttempt? ActiveAttempt { get; set; } +} + +/// 进行中的助手尝试(基线里的可重建状态) +internal sealed class DshAssistantStreamAttempt { + /// 尝试身份(start/end 帧共用) + public string AttemptId { get; set; } = ""; + /// 该尝试开始时的持久游标(之后的帧都属于这条消息) + public long StartedAfterSeq { get; set; } + /// 轮次号 + public int Turn { get; set; } + /// 步骤号 + public int Step { get; set; } + /// 下一条 chunk 帧的序号(前端据此校验不跳号) + public int NextIndex { get; set; } + /// 已收到的 chunk 记录(供前端展开重建,不含 revision) + public List Stream { get; set; } = []; +} + +/// 尝试内的一条 chunk 记录(基线里只允许 chunk 形状) +internal sealed class DshAssistantStreamRecord { + /// 固定为 chunk + public string Type { get; set; } = "chunk"; + /// 产出时间(毫秒时间戳) + public long Time { get; set; } + /// delta 载荷(text-delta / reasoning-delta) + public JsonElement Chunk { get; set; } +} + +/// 逐字流信封(推给 session/follow 流的瞬态项,type 用于前端分流) +internal sealed class DshAssistantStreamEnvelope { + /// 固定为 assistant-stream + public string Type { get; set; } = "assistant-stream"; + /// 帧内容 + public DshAssistantStreamFrame Frame { get; set; } = new(); +} + +/// 逐字流的一帧:start → chunk* → end(瞬态,不落日志、不占序号) +internal sealed class DshAssistantStreamFrame { + /// 帧类型(start / chunk / end) + public string Type { get; set; } = "start"; + /// 尝试身份 + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? AttemptId { get; set; } + /// 流版本(每帧 +1,前端强校验) public int Revision { get; set; } + /// 尝试开始时的持久游标(仅 start) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? StartedAfterSeq { get; set; } + /// 轮次号(仅 start) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Turn { get; set; } + /// 步骤号(仅 start) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Step { get; set; } + /// chunk 序号(仅 chunk,从 0 起) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Index { get; set; } + /// 产出时间(仅 chunk,毫秒时间戳) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Time { get; set; } + /// delta 载荷(仅 chunk) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public JsonElement? Chunk { get; set; } + /// 结算(仅 end) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DshAssistantStreamOutcome? Outcome { get; set; } +} + +/// 流帧的结算:committed 指向已落盘的助手消息事件,abandoned 表示本轮作废 +internal sealed class DshAssistantStreamOutcome { + /// 结算类型(committed / abandoned) + public string Kind { get; set; } = "abandoned"; + /// 落盘事件类型(committed 时固定 assistant/message) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? EventType { get; set; } + /// 落盘事件序号(committed 时必须等于 assistant/message 的 seq) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? Seq { get; set; } +} + +/// 文本/思考 delta(组装器容忍纯 delta 协议,无需 block-start/block-end) +internal sealed class DshDeltaChunk { + /// delta 类型(text-delta / reasoning-delta) + public string Type { get; set; } = "text-delta"; + /// 内容块下标(按块首次出现顺序从 0 分配:思考先到则 0、正文 1;纯正文轮正文 0) + public int Index { get; set; } + /// 本次追加的文本 + public string Text { get; set; } = ""; } /// 模型目录 @@ -1379,6 +1646,12 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(DshWorkflowRunEndData))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(DshAssistantStreamBaseline))] +[JsonSerializable(typeof(DshAssistantStreamAttempt))] +[JsonSerializable(typeof(DshAssistantStreamRecord))] +[JsonSerializable(typeof(DshAssistantStreamFrame))] +[JsonSerializable(typeof(DshAssistantStreamEnvelope))] +[JsonSerializable(typeof(DshAssistantStreamOutcome))] +[JsonSerializable(typeof(DshDeltaChunk))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(DshModelCatalog))] [JsonSerializable(typeof(DshSessionSelectModelArgs))] @@ -1394,6 +1667,8 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(DshEventsResultArgs))] [JsonSerializable(typeof(DshEventOutcome))] [JsonSerializable(typeof(DshEventsResultValue))] +[JsonSerializable(typeof(DshApprovalAskedData))] +[JsonSerializable(typeof(DshApprovalDecidedData))] [JsonSerializable(typeof(DshEventErrorBody))] [JsonSerializable(typeof(DshCommandInfo))] [JsonSerializable(typeof(DshCommandInputInfo))] @@ -1419,6 +1694,18 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(DshWorkspaceFilesReadResult))] [JsonSerializable(typeof(DshWorkspaceFilesReadBytesResult))] [JsonSerializable(typeof(DshWorkspaceFilesStatResult))] +[JsonSerializable(typeof(DshFileChangeFrame))] +[JsonSerializable(typeof(DshChangeReady))] +[JsonSerializable(typeof(DshGoal))] +[JsonSerializable(typeof(DshGoalRef))] +[JsonSerializable(typeof(DshGoalsRefArgs))] +[JsonSerializable(typeof(DshGoalsCreateArgs))] +[JsonSerializable(typeof(DshGoalsCreateResult))] +[JsonSerializable(typeof(DshGoalsEditArgs))] +[JsonSerializable(typeof(DshGoalChanges))] +[JsonSerializable(typeof(DshGoalsClearResult))] +[JsonSerializable(typeof(DshGoalsDocument))] +[JsonSerializable(typeof(DshGoalActivationChanged))] [JsonSerializable(typeof(DshFileUploadsUploadArgs))] [JsonSerializable(typeof(DshFileUploadResult))] [JsonSerializable(typeof(DshAttachmentRecord))] diff --git a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs index c48180a33e..51835b9982 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs @@ -131,6 +131,8 @@ private async Task ProjectAsync( AsOfSeq = session.Projections.AsOfSeq, Values = session.Projections.Values, }, + // 打开流时调用方会用 SubscribeSession 的原子基线覆盖(ADR 0147);这里仅供只读快照使用 + AssistantStream = hub?.AssistantBaseline(sessionId) ?? new(), }; } diff --git a/native_gui/src/NativeGui.Core/web_host/DshSessionStreamHub.cs b/native_gui/src/NativeGui.Core/web_host/DshSessionStreamHub.cs index 0482212a77..ea0d317346 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshSessionStreamHub.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshSessionStreamHub.cs @@ -53,12 +53,236 @@ private void ApplyLoaded(string sessionId, DshSessionEvent[] events) { /// 前端流身份 /// 推送一条事件的回调(由 WebSocket 层串行发送) /// 该流已经拿到的最后一个序号(快照光标;无快照为 -1) - internal IDisposable Subscribe(string sessionId, string streamId, Func send, long lastSeq = -1) { - var subscription = new Subscription(sessionId, streamId, send) { LastSeq = lastSeq }; + internal IDisposable Subscribe(string sessionId, string streamId, Func send, long lastSeq = -1) => + SubscribeCore(sessionId, streamId, send, null, lastSeq, activated: true).Lease; + + /// + /// 原子地「读助手流基线 + 登记订阅」(ADR 0147):返回的基线就是该流应使用的起点, + /// 之后的帧从 Baseline.Revision + 1 续上;未 前帧先缓存。 + /// + /// 会话 ID + /// 前端流身份 + /// 持久事件推送回调 + /// 瞬态帧推送回调(null = 该流未订阅 assistantStream) + /// 该流已经拿到的最后一个持久序号(快照光标) + /// 该流起点处的助手流基线,以及注销句柄 + internal (DshAssistantStreamBaseline Baseline, IDisposable Lease) SubscribeSession( + string sessionId, + string streamId, + Func send, + Func? sendAssistantFrame, + long lastSeq = -1) => + SubscribeCore(sessionId, streamId, send, sendAssistantFrame, lastSeq, activated: false); + + /// 登记订阅并在同一把锁内取基线(激活态决定帧立即发送还是先缓存) + private (DshAssistantStreamBaseline Baseline, IDisposable Lease) SubscribeCore( + string sessionId, + string streamId, + Func send, + Func? sendAssistantFrame, + long lastSeq, + bool activated) { + var subscription = new Subscription(sessionId, streamId, send, sendAssistantFrame) { + LastSeq = lastSeq, + Activated = activated, + }; lock (_gate) { _subscriptions[streamId] = subscription; + return (BaselineCore(sessionId), new Unsubscriber(this, streamId)); + } + } + + /// + /// 打开流的开场载荷(快照等)已发出:把订阅期间缓存的帧按序补发。 + /// 必须在快照发送之后调用,否则前端会先收到帧、后知基线而断流。 + /// + /// 前端流身份 + internal async Task ActivateAsync(string streamId) { + var (subscription, buffered) = TakeBuffered(streamId); + if (subscription is not null && buffered.Length > 0) { + await SendAssistantAsync(subscription, buffered); + } + } + + /// 激活订阅并取走缓存帧(同步持锁,await 一律在锁外) + private (Subscription? Subscription, DshAssistantStreamFrame[] Buffered) TakeBuffered(string streamId) { + lock (_gate) { + if (!_subscriptions.TryGetValue(streamId, out var subscription)) { + return (null, []); + } + subscription.Activated = true; + var buffered = subscription.PendingAssistant.ToArray(); + subscription.PendingAssistant.Clear(); + return (subscription, buffered); + } + } + + /// 该会话当前的助手流基线(重连重建与快照下发共用) + /// 会话 ID + internal DshAssistantStreamBaseline AssistantBaseline(string sessionId) { + lock (_gate) { + return BaselineCore(sessionId); + } + } + + /// 开一条成功尝试(本轮首个 delta 到达时):分配 start 帧并置为进行中 + /// 会话 ID + /// 轮次号 + /// 步骤号 + internal async Task StartAssistantAttemptAsync(string sessionId, int turn, int step) { + var (frame, targets) = AllocateStart(sessionId, turn, step); + await BroadcastAssistantAsync(targets, frame); + } + + /// 分配 start 帧(同步持锁) + private (DshAssistantStreamFrame Frame, List<(Subscription Subscription, bool Direct)> Targets) AllocateStart(string sessionId, int turn, int step) { + lock (_gate) { + var state = State(sessionId); + var attempt = new AssistantAttempt { + AttemptId = Guid.NewGuid().ToString("N"), + StartedAfterSeq = state.Cursor, + Turn = turn, + Step = step, + }; + state.Assistant = attempt; + state.AssistantRevision += 1; + var frame = new DshAssistantStreamFrame { + Type = "start", + AttemptId = attempt.AttemptId, + Revision = state.AssistantRevision, + StartedAfterSeq = attempt.StartedAfterSeq, + Turn = turn, + Step = step, + }; + return (frame, TargetsCore(sessionId, frame)); + } + } + + /// 追加一条 delta:帧携带尝试内递增的 chunk 序号,同时把记录进基线 + /// 会话 ID + /// delta 类型(text-delta / reasoning-delta) + /// 内容块下标(reasoning=0、正文=1) + /// 本次追加文本 + internal async Task AppendAssistantChunkAsync(string sessionId, string chunkType, int blockIndex, string text) { + var (frame, targets) = AllocateChunk(sessionId, chunkType, blockIndex, text); + if (frame is null) { + return; + } + await BroadcastAssistantAsync(targets, frame); + } + + /// 分配 chunk 帧(同步持锁;无进行中尝试时返回 null 并记日志) + private (DshAssistantStreamFrame? Frame, List<(Subscription Subscription, bool Direct)> Targets) AllocateChunk(string sessionId, string chunkType, int blockIndex, string text) { + lock (_gate) { + var state = State(sessionId); + if (state.Assistant is not { } attempt) { + GuiDiag.Log($"[DSH-WEB] assistant chunk without attempt session={sessionId} type={chunkType}"); + return (null, []); + } + var time = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + var chunk = JsonSerializer.SerializeToElement(new DshDeltaChunk { + Type = chunkType, + Index = blockIndex, + Text = text, + }, DshJsonContext.Default.DshDeltaChunk); + attempt.Stream.Add(new DshAssistantStreamRecord { Time = time, Chunk = chunk }); + state.AssistantRevision += 1; + var frame = new DshAssistantStreamFrame { + Type = "chunk", + AttemptId = attempt.AttemptId, + Revision = state.AssistantRevision, + Index = attempt.NextIndex, + Time = time, + Chunk = chunk, + }; + attempt.NextIndex += 1; + return (frame, TargetsCore(sessionId, frame)); } - return new Unsubscriber(this, streamId); + } + + /// 结算尝试:committed 指向已落盘的助手消息,abandoned 表示取消/失败 + /// 会话 ID + /// 结算内容 + internal async Task EndAssistantAttemptAsync(string sessionId, DshAssistantStreamOutcome outcome) { + var (frame, targets) = AllocateEnd(sessionId, outcome); + if (frame is null) { + return; + } + await BroadcastAssistantAsync(targets, frame); + } + + /// 分配 end 帧并清空进行中尝试(同步持锁) + private (DshAssistantStreamFrame? Frame, List<(Subscription Subscription, bool Direct)> Targets) AllocateEnd(string sessionId, DshAssistantStreamOutcome outcome) { + lock (_gate) { + var state = State(sessionId); + if (state.Assistant is not { } attempt) { + return (null, []); + } + state.Assistant = null; + state.AssistantRevision += 1; + var frame = new DshAssistantStreamFrame { + Type = "end", + AttemptId = attempt.AttemptId, + Revision = state.AssistantRevision, + // 前端按 end.index 校验「chunk 序号连续」:必须等于下一条 chunk 序号 + Index = attempt.NextIndex, + Outcome = outcome, + }; + return (frame, TargetsCore(sessionId, frame)); + } + } + + /// 取订阅者与该帧(调用方持锁):未激活的先缓存,激活的直接发送 + private List<(Subscription Subscription, bool Direct)> TargetsCore(string sessionId, DshAssistantStreamFrame frame) { + var targets = new List<(Subscription, bool)>(); + foreach (var subscription in _subscriptions.Values.Where(subscription => subscription.SessionId == sessionId && subscription.SendAssistant is not null)) { + if (subscription.Activated) { + targets.Add((subscription, true)); + } else { + subscription.PendingAssistant.Add(frame); + } + } + return targets; + } + + /// 把帧推给各订阅者(发送失败只记日志:前端会走 repair 重连,不拖垮其它流) + private static async Task BroadcastAssistantAsync(List<(Subscription Subscription, bool Direct)> targets, DshAssistantStreamFrame frame) { + foreach (var (subscription, direct) in targets) { + if (direct) { + await SendAssistantAsync(subscription, [frame]); + } + } + } + + /// 按序发送一批帧 + private static async Task SendAssistantAsync(Subscription subscription, IReadOnlyList frames) { + if (subscription.SendAssistant is not { } send) { + return; + } + foreach (var frame in frames) { + try { + await send(frame); + } catch (Exception ex) { + GuiDiag.Log($"[DSH-WEB] assistant frame push failed stream={subscription.StreamId}: {ex.Message}"); + return; + } + } + } + + /// 基线快照(调用方持锁) + private DshAssistantStreamBaseline BaselineCore(string sessionId) { + var state = State(sessionId); + return new DshAssistantStreamBaseline { + Revision = state.AssistantRevision, + ActiveAttempt = state.Assistant is { } attempt ? new DshAssistantStreamAttempt { + AttemptId = attempt.AttemptId, + StartedAfterSeq = attempt.StartedAfterSeq, + Turn = attempt.Turn, + Step = attempt.Step, + NextIndex = attempt.NextIndex, + Stream = [.. attempt.Stream], + } : null, + }; } /// 把订阅者尚未收到的历史事件补发一遍(打开流送出快照后调用,闭合「快照与订阅之间」的窗口) @@ -203,7 +427,27 @@ internal int RunningTurn(string sessionId) { /// 事件类型 /// 事件载荷(已注册到 ) /// 会话表面操作(消息类事件用 append) - internal async Task PublishAsync(string sessionId, string type, T data, string? surfaceOp = null) { + /// 该事件分配的序号(结算帧的 outcome.seq 指向它) + internal async Task PublishAsync(string sessionId, string type, T data, string? surfaceOp = null) { + var (sessionEvent, targets) = Allocate(sessionId, type, data, surfaceOp); + if (_journal is not null) { + await _journal.AppendAsync(sessionId, sessionEvent); + } + foreach (var target in targets) { + await DeliverAsync(target, PendingFor(target, sessionId)); + } + return sessionEvent.Seq; + } + + /// + /// 用已序列化的载荷分配序号并推送(审批审计等由外部构造 的场景, + /// 避免经 object 泛型序列化丢失具体形状)。 + /// + /// 会话 ID + /// 事件类型 + /// 事件载荷(已是 JsonElement) + /// 会话表面操作(消息类事件用 append) + internal async Task PublishElementAsync(string sessionId, string type, JsonElement data, string? surfaceOp = null) { var (sessionEvent, targets) = Allocate(sessionId, type, data, surfaceOp); if (_journal is not null) { await _journal.AppendAsync(sessionId, sessionEvent); @@ -279,9 +523,19 @@ private void Unsubscribe(string streamId) { } /// 一条打开的事件流 - private sealed record Subscription(string SessionId, string StreamId, Func Send) { + private sealed record Subscription( + string SessionId, + string StreamId, + Func Send, + Func? SendAssistant) { /// 已推给该流的最后序号(初始为快照光标) internal long LastSeq { get; set; } = -1; + + /// 开场载荷是否已发出(未发出时 assistant 帧先缓存,避免早于快照到达) + internal bool Activated { get; set; } + + /// 等待激活期间收到的 assistant 帧(按 revision 升序) + internal List PendingAssistant { get; } = []; } /// 会话的事件游标与生成状态 @@ -299,10 +553,32 @@ private sealed class SessionState { /// 该会话已分配的全部事件(与日志文件一致) internal List History { get; } = []; + /// 助手流版本(跨尝试单调递增,前端逐帧校验 +1) + internal int AssistantRevision { get; set; } + + /// 进行中的助手尝试(空闲为 null) + internal AssistantAttempt? Assistant { get; set; } + /// 取下一个序号(并推进游标) internal long NextSeq() => ++Cursor; } + /// 进行中的助手尝试(基线重建用) + private sealed class AssistantAttempt { + /// 尝试身份 + internal string AttemptId { get; init; } = ""; + /// 开始时的持久游标 + internal long StartedAfterSeq { get; init; } + /// 轮次号 + internal int Turn { get; init; } + /// 步骤号 + internal int Step { get; init; } + /// 下一条 chunk 帧序号 + internal int NextIndex { get; set; } + /// 已产出的 chunk 记录 + internal List Stream { get; } = []; + } + /// 订阅句柄 private sealed class Unsubscriber(DshSessionStreamHub hub, string streamId) : IDisposable { private int _disposed; diff --git a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs index 8424a47b21..badb885298 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs @@ -39,22 +39,40 @@ internal sealed class DshTurnRunner { } _services.Streams.Observe(request.SessionId, snapshot.Cursor, MaxTurn(snapshot.Records)); if (_services.Streams.TryBeginTurn(request.SessionId) is not { } turn) { + // 轮内注入(B11):轮次运行中不排队、不开新轮,直接投递到引擎邮箱(DSH agent.steer) + if (string.Equals(request.Mode, "steer", StringComparison.Ordinal)) { + return TrySteer(engine, request.SessionId, text); + } if (string.Equals(request.Mode, "queue", StringComparison.Ordinal)) { Queue(request.SessionId, NewPending(request.RequestId, text)); return null; } - return ("session/busy", $"会话 {request.SessionId} 上一轮仍在生成;等它结束(接口完成/停止)后再发送,排队请用 mode=queue"); + return ("session/busy", $"会话 {request.SessionId} 上一轮仍在生成;等它结束(接口完成/停止)后再发送。选项:①轮内注入请在请求里带 mode=steer;②排队等下一轮请用 mode=queue;③停止当前轮用 session/cancel"); } await BeginTurnAsync(engine, request.SessionId, NewPending(request.RequestId, text), turn); return null; } + /// + /// 投递轮内注入(轮次运行中)。引擎未装配邮箱时诚实拒绝并引导(不静默丢弃、不改排队语义)。 + /// + /// 引擎会话 + /// 会话 ID + /// 已校验文本 + /// 失败时的错误码与引导文案;受理成功返回 null + private static (string Code, string Message)? TrySteer(Hosting.IJccChatSession engine, string sessionId, string text) => + engine.TrySteerInput(sessionId, text) + ? null + : ("session/steer-unavailable", + $"会话 {sessionId} 的引擎未装配轮内注入邮箱(ISteerInputMailbox);请重启宿主。当前输入未被丢弃:改用 mode=queue 会在本轮结束后顺序执行"); + /// 落用户消息并启动后台轮次(开启/排队复用) /// 引擎会话 /// 会话 ID /// 已校验的输入 /// 轮次占用 private async Task BeginTurnAsync(Hosting.IJccChatSession engine, string sessionId, PendingPrompt prompt, (int Turn, CancellationToken Token) turn) { + _services.Goals.IncrementRounds(sessionId); await _services.Streams.PublishAsync(sessionId, "turn/start", new DshTurnStartData { Turn = turn.Turn }); await _services.Streams.PublishAsync(sessionId, "step/start", new DshStepData { Turn = turn.Turn, Step = Step }); await _services.Streams.PublishAsync(sessionId, "user/message", new DshWireMessage { @@ -92,6 +110,8 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId _services.Approvals.SetAgent(sessionId); // 子代理轮内运行追踪(tool-workflow 事件族,TASK058 B6)— try 外声明,取消/异常分支也要收尾 var agents = new DshAgentRunTracker($"{sessionId}:{turn}:agents"); + // 助手流尝试是否已开启(取消/失败分支也要据此补 abandoned 结算) + var streaming = false; try { engine.SwitchSession(sessionId); if (await _services.Sessions.HistoryAsync(sessionId, cancellationToken) is { Count: > 0 } history) { @@ -100,8 +120,19 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId GuiDiag.Log($"[DSH-WEB] turn streaming session={sessionId} turn={turn}"); var content = new StringBuilder(); var thinking = new StringBuilder(); + // 内容块下标按首次出现的块顺序分配(reasoning 先到则 0、正文 1;纯正文轮正文 0)。 + // 固定槽位会留下 0 号空洞,前端块数组首元素缺失 → 流式正文渲染不出来(DSH 语义:下标即内容块序号) + var blockIndexes = new Dictionary(StringComparer.Ordinal); + int BlockIndex(string family) { + if (!blockIndexes.TryGetValue(family, out var index)) { + index = blockIndexes.Count; + blockIndexes[family] = index; + } + return index; + } TokenUsage? usage = null; var events = 0; + var steers = 0; var calls = new List<(string CallId, string Name, string Arguments)>(); await foreach (var evt in engine.StreamAsync(text, cancellationToken)) { events += 1; @@ -110,9 +141,14 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId } if (evt.Type == ChatStreamEventType.Content && !string.IsNullOrEmpty(evt.Content)) { content.Append(evt.Content); + // 逐字流(ADR 0147):首条 delta 开尝试,之后每字符一帧;结算在落盘后发 end + streaming = await StartAssistantStreamAsync(sessionId, turn, streaming); + await _services.Streams.AppendAssistantChunkAsync(sessionId, "text-delta", BlockIndex("text"), evt.Content); } else if (evt.Type == ChatStreamEventType.Thinking && !string.IsNullOrEmpty(evt.ThinkingContent)) { // 思考内容 → assistant/message 前置 reasoning 块(TASK058 B4,前端折叠渲染) thinking.Append(evt.ThinkingContent); + streaming = await StartAssistantStreamAsync(sessionId, turn, streaming); + await _services.Streams.AppendAssistantChunkAsync(sessionId, "reasoning-delta", BlockIndex("reasoning"), evt.ThinkingContent); } else if (evt.Type == ChatStreamEventType.Complete && evt.Usage is { } turnUsage) { // 用量 → data.usage 四桶(前端"本轮用量"弹窗,TASK058 B4) usage = turnUsage; @@ -122,28 +158,39 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId GuiDiag.Log($"[DSH-WEB] turn system notice session={sessionId}: {evt.Content}"); } else if (evt.Type == ChatStreamEventType.ToolCallEnd || evt.Type == ChatStreamEventType.ToolCallStart) { await PublishToolCallAsync(sessionId, turn, evt, calls); + } else if (evt.Type == ChatStreamEventType.SteerInput && !string.IsNullOrEmpty(evt.Content)) { + // 轮内注入在步首已进入上下文 → 这里补投影为轮内 user/message(DSH :1061 语义) + steers += 1; + await PublishSteerMessageAsync(sessionId, turn, evt.Content, steers); } } GuiDiag.Log($"[DSH-WEB] turn stream done session={sessionId} events={events} chars={content.Length} thinking={thinking.Length}"); - await PublishReplyAsync(sessionId, turn, thinking.Length > 0 ? thinking.ToString() : null, content.ToString(), usage, engine); + var replySeq = await PublishReplyAsync(sessionId, turn, thinking.Length > 0 ? thinking.ToString() : null, content.ToString(), usage, engine); + if (streaming) { + // end 必须在 assistant/message 落盘之后:outcome.seq 指向它,前端据此把流式文本换成持久消息 + await _services.Streams.EndAssistantAttemptAsync(sessionId, replySeq is { } committedSeq + ? new DshAssistantStreamOutcome { Kind = "committed", EventType = "assistant/message", Seq = committedSeq } + : new DshAssistantStreamOutcome { Kind = "abandoned" }); + } await PublishWorkflowRunEndAsync(sessionId, agents, "completed"); - await EndTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "completed" }); + await FinishTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "completed" }); } catch (OperationCanceledException) { GuiDiag.Log($"[DSH-WEB] turn cancelled session={sessionId} turn={turn}"); + await AbandonAssistantStreamAsync(sessionId, streaming); await PublishWorkflowRunEndAsync(sessionId, agents, "cancelled"); - await EndTurnAsync(sessionId, turn, new DshTurnEndReason { + await FinishTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "aborted", Reason = new DshTurnEndCancelCause { Kind = "user" }, }); } catch (Exception ex) { GuiDiag.Log($"[DSH-WEB] turn failed session={sessionId} turn={turn}: {ex.Message}"); + await AbandonAssistantStreamAsync(sessionId, streaming); await PublishWorkflowRunEndAsync(sessionId, agents, "error"); - await EndTurnAsync(sessionId, turn, new DshTurnEndReason { + await FinishTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "error", Error = new DshTurnEndError { Code = "UNKNOWN", Message = ex.Message }, }); } finally { - _services.Approvals.SetAgent(null); _services.Streams.EndTurn(sessionId); if (_services.Engine is { } ready) { StartQueued(ready, sessionId); @@ -151,6 +198,19 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId } } + /// + /// 收尾一轮:**先**结清审批审计对(挂起确认结算 + 断流补写排空),**再**落 turn/end。 + /// 顺序不可颠倒:DSH 要求 approval/asked/approval/decided 必须被开放轮次包住 + /// (invariant.js 判定 outside an open turn 即错),取消/失败路径同样适用。 + /// + /// 会话 ID + /// 轮次号 + /// 轮次结束原因 + private async Task FinishTurnAsync(string sessionId, int turn, DshTurnEndReason reason) { + await _services.Approvals.CloseTurnAsync(sessionId); + await EndTurnAsync(sessionId, turn, reason); + } + /// 发布子代理事件族(agent start/end → tool-workflow,TASK058 B6) private async Task PublishAgentEventsAsync(string sessionId, DshAgentRunTracker agents, ChatStreamEvent evt) { var events = evt.Type == ChatStreamEventType.AgentStarted @@ -204,13 +264,37 @@ private async Task PublishToolCallAsync(string sessionId, int turn, ChatStreamEv }, surfaceOp: "append"); } + /// 首条 delta 时开启助手流尝试(已开启则原样返回) + /// 会话 ID + /// 轮次号 + /// 当前是否已开启 + /// 开启后的状态(恒为 true) + private async Task StartAssistantStreamAsync(string sessionId, int turn, bool streaming) { + if (streaming) { + return true; + } + await _services.Streams.StartAssistantAttemptAsync(sessionId, turn, Step); + return true; + } + + /// 取消/失败时作废进行中的助手流(前端丢弃半截气泡,等下一轮重建) + /// 会话 ID + /// 本轮是否开启过助手流 + private async Task AbandonAssistantStreamAsync(string sessionId, bool streaming) { + if (!streaming) { + return; + } + await _services.Streams.EndAssistantAttemptAsync(sessionId, new DshAssistantStreamOutcome { Kind = "abandoned" }); + } + /// 落助手回复事件(空块序列不落,避免空气泡;思考/正文/用量经投影单点翻译,TASK058 B4) - private async Task PublishReplyAsync(string sessionId, int turn, string? thinking, string reply, TokenUsage? usage, Hosting.IJccChatSession engine) { + /// 该事件的序号(无内容未落盘时返回 null,供结算帧引用) + private async Task PublishReplyAsync(string sessionId, int turn, string? thinking, string reply, TokenUsage? usage, Hosting.IJccChatSession engine) { var (content, mappedUsage) = DshAssistantReplyProjection.Project(thinking, reply, usage); if (content.Count == 0) { - return; + return null; } - await _services.Streams.PublishAsync(sessionId, "assistant/message", new DshAssistantMessageData { + return await _services.Streams.PublishAsync(sessionId, "assistant/message", new DshAssistantMessageData { Turn = turn, Step = Step, Message = new DshWireMessage { @@ -227,6 +311,23 @@ private async Task PublishReplyAsync(string sessionId, int turn, string? thinkin }, surfaceOp: "append"); } + /// + /// 落一条轮内注入的用户消息(轮内 user/message,surfaceOp=append): + /// 与开轮时的首条用户消息同形,前端据此在对话流中插入新的用户气泡(不新开轮次)。 + /// + /// 会话 ID + /// 轮次号 + /// 注入文本 + /// 本轮第几条注入(保证消息 ID 唯一) + private async Task PublishSteerMessageAsync(string sessionId, int turn, string text, int index) { + await _services.Streams.PublishAsync(sessionId, "user/message", new DshWireMessage { + Id = $"{sessionId}:{turn}:steer:{index}", + Role = "user", + Content = [new DshWireContentBlock { Text = text }], + Source = new DshWireMessageSource { Kind = "user" }, + }, surfaceOp: "append"); + } + /// 落步骤结束与轮次结束(先 step/end 再 turn/end) private async Task EndTurnAsync(string sessionId, int turn, DshTurnEndReason reason) { await _services.Streams.PublishAsync(sessionId, "step/end", new DshStepData { Turn = turn, Step = Step }); diff --git a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs index f2f123be9d..4870116f32 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs @@ -265,10 +265,28 @@ private static async Task SendItemAsync(WebSocket socket, SemaphoreSlim gate, Ds var clientId = Guid.NewGuid().ToString(); Func sendFrame = frame => send(Item(open.StreamId, frame)); _services.Approvals.Attach(sendFrame, clientId); + // goal/activation-changed 事件桥(TASK058 P2-B5):activation 翻转即推 waterfall 帧 + void OnGoalChanged(string sessionId, DshGoalDocument goal) { + _ = sendFrame(JsonSerializer.SerializeToElement(new DshWaterfallFrame { + Event = "goal/activation-changed", + EventId = Guid.NewGuid().ToString("N"), + AgentId = sessionId, + Request = JsonSerializer.SerializeToElement(new DshGoalActivationChanged { + SessionId = sessionId, + Goal = new DshGoalActivation { Id = goal.Id, Revision = goal.Revision, Activation = goal.Activation }, + }, DshJsonContext.Default.DshGoalActivationChanged), + }, DshJsonContext.Default.DshWaterfallFrame)).ContinueWith( + static t => GuiDiag.Log($"[DSH-GOALS] activation frame send failed: {t.Exception?.Message}"), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + _services.Goals.GoalChanged += OnGoalChanged; return ([Item(open.StreamId, JsonSerializer.SerializeToElement(new DshEventsReady { ClientId = clientId, Host = new DshHostInfo { Home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) }, - }, DshJsonContext.Default.DshEventsReady))], new DetachLease(() => _services.Approvals.Detach()), null); + }, DshJsonContext.Default.DshEventsReady))], new DetachLease(() => { + _services.Goals.GoalChanged -= OnGoalChanged; + _services.Approvals.Detach(); + }), null); } case "workspace/follow": { var baseline = await _services.Workspaces.ReadBaselineAsync(cancellationToken); @@ -283,12 +301,53 @@ private static async Task SendItemAsync(WebSocket socket, SemaphoreSlim gate, Ds } case "session/follow": return await OpenSessionAsync(open, send, cancellationToken); + case "workspaceFiles/changes": + return await OpenWorkspaceFilesChangesAsync(open, send, cancellationToken); default: GuiDiag.Log($"[DSH-WEB] stream endpoint unimplemented: {open.Endpoint}"); return ([], null, null); } } + /// + /// 打开文件变更流(TASK058 P2-B4):ready 首帧 + 后台泵把 watcher 变更帧推到连接; + /// 关闭时取消泵并释放 watcher 租约(幂等)。会话/路径非法时发可执行引导错误帧。 + /// + private async Task<(IReadOnlyList Opening, IDisposable? Lease, Func? Ready)> OpenWorkspaceFilesChangesAsync( + DshStreamOpen open, Func send, CancellationToken cancellationToken) { + var request = open.Payload?.Args.Deserialize(DshJsonContext.Default.DshWorkspaceFilesArgs); + if (request is null || string.IsNullOrWhiteSpace(request.WorkspaceFileScopeId)) { + var raw = open.Payload?.Args.ToString() ?? ""; + return ([Item(open.StreamId, StreamError("workspaceFiles/rejected", + $"workspaceFiles/changes 需要 {{workspaceFileScopeId, path}};收到 args={raw[..Math.Min(120, raw.Length)]}"))], null, null); + } + var feed = await _services.WorkspaceFiles.CreateChangeFeedAsync(request.WorkspaceFileScopeId, request.Path, cancellationToken); + if (feed.Error is { } error) { + return ([Item(open.StreamId, StreamError(error.Error?.Code ?? "workspaceFiles/rejected", error.Error?.Message ?? "变更流打开失败"))], null, null); + } + var frames = feed.Frames ?? throw new InvalidOperationException("变更流产物缺帧通道(内部不变量破坏)"); + var pumpCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var pump = Task.Run(async () => { + try { + await foreach (var frame in frames.Reader.ReadAllAsync(pumpCts.Token).ConfigureAwait(false)) { + await send(Item(open.StreamId, frame)).ConfigureAwait(false); + } + } catch (OperationCanceledException) { + // 连接关闭/宿主停机 — 泵静默收尾 + } catch (Exception ex) { + GuiDiag.Log($"[DSH-WEB] changes pump failed: {ex.Message}"); + } + }, pumpCts.Token); + return ( + [Item(open.StreamId, JsonSerializer.SerializeToElement(new DshChangeReady(), DshJsonContext.Default.DshChangeReady))], + new DetachLease(() => { + pumpCts.Cancel(); + feed.Lease?.Dispose(); + pumpCts.Dispose(); + }), + null); + } + /// 打开会话事件流的首帧:宿主事件日志(或转录投影)→ snapshot + 订阅后续事件;否则给出可执行的引导错误 private async Task<(IReadOnlyList Opening, IDisposable? Lease, Func? Ready)> OpenSessionAsync( DshStreamOpen open, Func send, CancellationToken cancellationToken) { @@ -304,16 +363,38 @@ private static async Task SendItemAsync(WebSocket socket, SemaphoreSlim gate, Ds return ([Item(open.StreamId, StreamError("session/unknown", $"会话 {sessionId} 不在磁盘上(~/.jcc/sessions/{sessionId}/transcript.json 不存在);用 session/create 建一个新会话,或从侧栏的会话列表里选一个"))], null, null); } _services.Streams.Observe(sessionId, snapshot.Cursor, MaxTurn(snapshot.Records)); - var lease = _services.Streams.Subscribe(sessionId, open.StreamId, sessionEvent => send(Item( + var follow = FollowRequest(open); + // 基线必须与订阅在同一把锁里取(ADR 0147):否则快照与首帧之间可能漏帧 → 前端断流 + var (baseline, lease) = _services.Streams.SubscribeSession( + sessionId, open.StreamId, - JsonSerializer.SerializeToElement(new DshSessionRecord { Event = sessionEvent }, DshJsonContext.Default.DshSessionRecord))), + sessionEvent => send(Item( + open.StreamId, + JsonSerializer.SerializeToElement(new DshSessionRecord { Event = sessionEvent }, DshJsonContext.Default.DshSessionRecord))), + follow?.AssistantStream == true + ? frame => SendAssistantFrameAsync(open.StreamId, send, frame) + : null, snapshot.Cursor); + snapshot.AssistantStream = baseline; return ( [Item(open.StreamId, JsonSerializer.SerializeToElement(snapshot, DshJsonContext.Default.DshSessionFollowSnapshot))], lease, - () => _services.Streams.FlushAsync(open.StreamId)); + async () => { + // 开场项(快照)已发出:先补发持久事件,再放行缓存的瞬态帧 + await _services.Streams.FlushAsync(open.StreamId); + await _services.Streams.ActivateAsync(open.StreamId); + }); } + /// 包装一帧 assistant-stream(瞬态通道信封,前端按 type 分流) + /// 前端流身份 + /// 底层发送回调 + /// 帧内容 + private static Task SendAssistantFrameAsync(string streamId, Func send, DshAssistantStreamFrame frame) => + send(Item(streamId, JsonSerializer.SerializeToElement(new DshAssistantStreamEnvelope { + Frame = frame, + }, DshJsonContext.Default.DshAssistantStreamEnvelope))); + /// 快照事件页里的最大轮次(无事件为 0) private static int MaxTurn(IReadOnlyList records) => records @@ -326,12 +407,17 @@ private static int MaxTurn(IReadOnlyList records) => /// 流打开帧 /// 会话 ID;地址缺失或非 kind=session 时为 null internal static string? FollowSessionId(DshStreamOpen open) { + var address = FollowRequest(open)?.Address; + return address is null || address.Kind != "session" ? null : address.SessionId; + } + + /// 解出 session/follow 的请求体(args 是 {request:{...}} 信封) + /// 流打开帧 + private static DshSessionFollowArgs? FollowRequest(DshStreamOpen open) { if (open.Payload?.Args is not { ValueKind: JsonValueKind.Object } args) { return null; } - var envelope = args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionFollowArgs); - var address = envelope?.Request?.Address; - return address is null || address.Kind != "session" ? null : address.SessionId; + return args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionFollowArgs)?.Request; } /// 构造流式端点失败帧(引导式错误:说明期望参数与下一步可执行动作) diff --git a/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs index c5395b0d5f..6f68ca7e99 100644 --- a/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs +++ b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs @@ -131,4 +131,71 @@ private string VersionFor(string absolutePath) { private static DshRpcResult AsInternalError() => DshRpcResults.Fail( "workspaceFiles/rejected", "路径解析失败(内部错误);请刷新文件树后重试"); + + /// 变更帧通道产物(Lease 释放 = 解绑 watcher + 完成通道;宿主发 ready 帧后消费 Frames) + internal sealed record ChangeFeed(DshRpcResult? Error, Channel? Frames, IDisposable? Lease); + + /// + /// 创建目录变更帧流(workspaceFiles/changes,TASK058 P2-B4): + /// IFileSystem.Watch 统一工厂(内存实现自带写联动,物理实现包装 FileSystemWatcher 内置防抖); + /// 事件 → 帧:Created/Changed → {kind:"change", change:{absolutePath, version}}(version 复用 + /// size-mtime 合成),Deleted → change.absent=true。lease 释放即解绑并完成通道(幂等)。 + /// + /// 会话 ID(根 = 会话 cwd) + /// 相对目录路径 + /// 取消令牌 + internal async Task CreateChangeFeedAsync(string sessionId, string path, CancellationToken cancellationToken) { + var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken); + if (error is not null || absolutePath is null) { + return new ChangeFeed(error ?? AsInternalError(), null, null); + } + if (!fs.DirectoryExists(absolutePath)) { + return new ChangeFeed(DshRpcResults.Fail("workspaceFiles/not-found", + $"目录不存在:{path};在文件树里刷新后重试"), null, null); + } + var watcher = fs.Watch(absolutePath); + watcher.IncludeSubdirectories = false; + watcher.EnableRaisingEvents = true; + var frames = Channel.CreateUnbounded(new UnboundedChannelOptions { + SingleReader = true, + SingleWriter = false, + }); + void OnChange(object? sender, FileChangedEventArgs e) { + if (e.ChangeType == WatcherChangeTypes.Deleted) { + frames.Writer.TryWrite(JsonSerializer.SerializeToElement( + new DshFileChangeFrame { Change = new DshFileChange { AbsolutePath = e.FullPath, Absent = true } }, + DshJsonContext.Default.DshFileChangeFrame)); + return; + } + var version = fs.FileExists(e.FullPath) ? VersionFor(e.FullPath) : "0"; + frames.Writer.TryWrite(JsonSerializer.SerializeToElement( + new DshFileChangeFrame { Change = new DshFileChange { AbsolutePath = e.FullPath, Version = version } }, + DshJsonContext.Default.DshFileChangeFrame)); + } + watcher.Changed += OnChange; + watcher.Created += OnChange; + watcher.Deleted += OnChange; + var lease = new ChangeFeedLease(watcher, OnChange, frames); + return new ChangeFeed(null, frames, lease); + } + + /// 变更流租约 — 释放时解绑三事件 + 完成通道 + 异步释放 watcher(幂等) + private sealed class ChangeFeedLease(IFileSystemWatcher watcher, EventHandler onChange, Channel frames) : IDisposable { + private int _disposed; + + /// 解绑事件 + 完成通道 + 后台释放 watcher(幂等) + [SuppressMessage("AotSafety", "JCC9200", Justification = "AGENTS 资源规范允许的 fire-and-forget 后台释放:同步 Dispose 内不能 await 自己;异常经 ContinueWith 记日志不吞")] + public void Dispose() { + if (Interlocked.Exchange(ref _disposed, 1) != 0) { + return; + } + watcher.Changed -= onChange; + watcher.Created -= onChange; + watcher.Deleted -= onChange; + frames.Writer.TryComplete(); + _ = watcher.DisposeAsync().AsTask().ContinueWith( + static t => GuiDiag.Log($"[DSH-FILES] watcher dispose failed: {t.Exception?.Message}"), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + } } diff --git a/native_gui/tests/native_gui.core.tests/GlobalUsings.cs b/native_gui/tests/native_gui.core.tests/GlobalUsings.cs new file mode 100644 index 0000000000..f34cdf7404 --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/GlobalUsings.cs @@ -0,0 +1,45 @@ +// NativeGui.Core.Tests 全局 using(ADR 0145 B3):原 JoinCodeGui.Tests 清单剔除 Avalonia 19 条 +// 与 Theming(视觉测试归 Host.Tests)——纯 net10.0 零 UI 依赖由编译零警告兜底 +global using Core.Configuration; +global using Core.Hooks.Execution.Interception; +global using Core.Hooks.Execution.Interception.Guards; +global using Core.DependencyInjection; +global using FluentAssertions; +global using IO.FileSystem; +global using JoinCode.Abstractions.Configuration; +global using JoinCode.Abstractions.Configuration.Llm; +global using JoinCode.Abstractions.Configuration.Providers; +global using JoinCode.Abstractions.Configuration.Settings; +global using JoinCode.Abstractions.Interfaces; +global using JoinCode.Abstractions.LLM; +global using JoinCode.Abstractions.LLM.Chat; +global using JoinCode.Abstractions.Models.Agent; +global using JoinCode.Abstractions.Models.Diff; +global using JoinCode.Abstractions.Models.Interactive; +global using JoinCode.Abstractions.Prompts.ToolPrompts; +global using JoinCode.Abstractions.Security; +global using JoinCode.Abstractions.Security.Permission; +global using JoinCode.Abstractions.Security.Shell; +global using JoinCode.Abstractions.State; +global using JoinCode.Abstractions.Tools; +global using JoinCode.Gui.Hosting; +global using JoinCode.Gui.Persistence; +global using JoinCode.Gui.WebHost; +global using JoinCode.Gui.WebView; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.DependencyInjection; +global using System; +global using System.Collections.Concurrent; +global using System.Collections.ObjectModel; +global using System.Collections.Generic; +global using System.Collections.Frozen; +global using System.ComponentModel; +global using System.Globalization; +global using System.IO; +global using System.Linq; +global using System.Runtime.InteropServices; +global using System.Text; +global using System.Text.Json; +global using System.Threading.Channels; +global using System.Threading.Tasks; +global using Xunit; diff --git a/native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj b/native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj new file mode 100644 index 0000000000..e269a632dd --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj @@ -0,0 +1,39 @@ + + + + test + JoinCode.Gui.Tests + NativeGui.Core.Tests + NativeGui 协议核心测试(ADR 0145 B3):web_host/webview/persistence/hosting 契约 — 纯 net10.0 零 Avalonia,ubuntu CI 可跑 + + Exe + net10.0 + $(MSBuildWarningsAsMessages);MSB3277 + $(NoWarn);NU1903;xUnit1051 + + + + + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/native_gui/tests/join_code_gui.tests/hosting/GuiPermissionConfirmationHandlerTests.cs b/native_gui/tests/native_gui.core.tests/hosting/GuiPermissionConfirmationHandlerTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/GuiPermissionConfirmationHandlerTests.cs rename to native_gui/tests/native_gui.core.tests/hosting/GuiPermissionConfirmationHandlerTests.cs diff --git a/native_gui/tests/join_code_gui.tests/hosting/PlaceholderChatSessionTests.cs b/native_gui/tests/native_gui.core.tests/hosting/PlaceholderChatSessionTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/PlaceholderChatSessionTests.cs rename to native_gui/tests/native_gui.core.tests/hosting/PlaceholderChatSessionTests.cs diff --git a/native_gui/tests/join_code_gui.tests/hosting/SubAgentCompletionTests.cs b/native_gui/tests/native_gui.core.tests/hosting/SubAgentCompletionTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/SubAgentCompletionTests.cs rename to native_gui/tests/native_gui.core.tests/hosting/SubAgentCompletionTests.cs diff --git a/native_gui/tests/join_code_gui.tests/persistence/GuiPreferencesUnattendedTests.cs b/native_gui/tests/native_gui.core.tests/persistence/GuiPreferencesUnattendedTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/persistence/GuiPreferencesUnattendedTests.cs rename to native_gui/tests/native_gui.core.tests/persistence/GuiPreferencesUnattendedTests.cs diff --git a/native_gui/tests/join_code_gui.tests/persistence/GuiSessionStoreTests.cs b/native_gui/tests/native_gui.core.tests/persistence/GuiSessionStoreTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/persistence/GuiSessionStoreTests.cs rename to native_gui/tests/native_gui.core.tests/persistence/GuiSessionStoreTests.cs diff --git a/native_gui/tests/join_code_gui.tests/persistence/TurnLogPersistenceTests.cs b/native_gui/tests/native_gui.core.tests/persistence/TurnLogPersistenceTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/persistence/TurnLogPersistenceTests.cs rename to native_gui/tests/native_gui.core.tests/persistence/TurnLogPersistenceTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshApprovalBrokerTests.cs similarity index 58% rename from native_gui/tests/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshApprovalBrokerTests.cs index 6c3e9631a4..625b40a14e 100644 --- a/native_gui/tests/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs +++ b/native_gui/tests/native_gui.core.tests/web_host/DshApprovalBrokerTests.cs @@ -55,6 +55,11 @@ private static JsonElement FirstFrame(List sent) { lock (sent) { return sent[0]; } } + /// 取最近一帧的 eventId(同一桥发过多次 waterfall 时用) + private static string LastFrameEventId(List sent) { + lock (sent) { return sent[^1].GetProperty("eventId").GetString() ?? string.Empty; } + } + [Fact] public async Task Request_WithoutEventsStream_DeniesImmediately() { // $events 未连接(引擎先于前端就绪/前端已断开)→ fail-closed 拒绝,不挂起引擎轮次 @@ -139,7 +144,145 @@ public async Task Settle_SecondReplyIsIgnored_PendingAlreadySettled() { (await _broker.SettleAsync("client-1", eventId, Outcome("result", "rejected"))).Should().BeFalse(); } - // ---- DshRpcDispatcher 路由($events/result 与 permissionPresets/catalog)---- + // ---- 审批审计对(approval/asked + approval/decided,对齐 DSH dsh-user-approval)---- + + /// 捕获审计落库调用(宿主注入 Audit 委托的形状:sessionId/type/payload) + private List<(string SessionId, string Type, JsonElement Payload)> CaptureAudit() { + var entries = new List<(string, string, JsonElement)>(); + _broker.Audit = (sessionId, type, payload) => { + lock (entries) { entries.Add((sessionId, type, payload)); } + return Task.CompletedTask; + }; + return entries; + } + + /// 取审计条目快照(避免与落库回调并发读写) + private static List<(string SessionId, string Type, JsonElement Payload)> AuditSnapshot( + List<(string SessionId, string Type, JsonElement Payload)> entries) { + lock (entries) { return [.. entries]; } + } + + /// 发起一次「开放轮次内的确认」并在回帖后返回决策 + private async Task RoundInTurnAsync(DshEventOutcome outcome) { + _broker.SetAgent("s1"); + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + await _broker.SettleAsync("client-1", FrameEventId(sent), outcome); + return await pending; + } + + [Fact] + public async Task Request_InOpenTurn_AuditsAskedBeforeSettling_ThenDecided() { + var audit = CaptureAudit(); + _broker.SetAgent("s1"); + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + + // 前端尚未回帖:只应有 approval/asked(审计先于等待) + var askedOnly = AuditSnapshot(audit); + askedOnly.Should().HaveCount(1); + askedOnly[0].SessionId.Should().Be("s1"); + askedOnly[0].Type.Should().Be("approval/asked"); + var asked = askedOnly[0].Payload; + asked.GetProperty("id").GetString().Should().NotBeNullOrEmpty(); + asked.GetProperty("toolName").GetString().Should().Be("Bash"); + asked.GetProperty("reason").GetString().Should().Contain("权限确认"); + asked.GetProperty("callId").GetString().Should().Be("req-1"); + + await _broker.SettleAsync("client-1", FrameEventId(sent), Outcome("result", "allowed-once")); + (await pending).Should().Be(PermissionConfirmationDecision.Allow); + + // 回帖后闭合审计对:同 id、outcome=allowed-once + var pair = AuditSnapshot(audit); + pair.Should().HaveCount(2); + pair[1].Type.Should().Be("approval/decided"); + pair[1].Payload.GetProperty("id").GetString().Should().Be(asked.GetProperty("id").GetString()); + pair[1].Payload.GetProperty("outcome").GetString().Should().Be("allowed-once"); + } + + [Theory] + [InlineData("result", "rejected", "rejected")] + [InlineData("next", null, "unavailable")] + public async Task Request_AuditOutcomeVocabulary_MatchesDsh(string kind, string? value, string expected) { + var audit = CaptureAudit(); + await RoundInTurnAsync(Outcome(kind, value)); + + var decided = AuditSnapshot(audit).Single(entry => entry.Type == "approval/decided"); + decided.Payload.GetProperty("outcome").GetString().Should().Be(expected); + } + + [Fact] + public async Task Request_ListenerThrew_AuditsUnavailable() { + var audit = CaptureAudit(); + await RoundInTurnAsync(new DshEventOutcome { Kind = "rejected", Error = new DshEventErrorBody { Name = "Error", Message = "boom" } }); + + AuditSnapshot(audit).Single(entry => entry.Type == "approval/decided").Payload.GetProperty("outcome").GetString() + .Should().Be("unavailable"); + } + + [Fact] + public async Task Request_WithoutOpenTurn_SkipsAuditButStillDecides() { + var audit = CaptureAudit(); + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + await _broker.SettleAsync("client-1", FrameEventId(sent), Outcome("result", "allowed-once")); + + (await pending).Should().Be(PermissionConfirmationDecision.Allow); + AuditSnapshot(audit).Should().BeEmpty("没有开放轮次就不该落审计(DSH 要求审计对被轮次包住)"); + } + + [Fact] + public async Task CloseTurn_SettlesPendingAsCancelled_AndClosesAuditPair() { + var audit = CaptureAudit(); + _broker.SetAgent("s1"); + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + + // 轮次收尾(用户取消/引擎异常)→ 挂起确认结算为 cancelled,不残留 + await _broker.CloseTurnAsync("s1"); + (await pending).Should().Be(PermissionConfirmationDecision.Deny); + AuditSnapshot(audit).Single(entry => entry.Type == "approval/decided").Payload.GetProperty("outcome").GetString() + .Should().Be("cancelled"); + + // 目标会话已清空:后续请求不再落审计(等价「无开放轮次」) + audit.Clear(); + var later = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + await _broker.SettleAsync("client-1", LastFrameEventId(sent), Outcome("result", "rejected")); + await later; + AuditSnapshot(audit).Should().BeEmpty(); + } + + [Fact] + public async Task CloseTurn_WaitsForDetachedAudit_SoPairStillLandsInsideTurn() { + // 断流(前端刷新/关页)时挂起确认的审计补写是后台任务(释放路径禁阻塞,ADR 0093); + // 轮次收尾必须等它落库,否则 approval/decided 会排到 turn/end 之后 —— 违反 DSH「审计对被开放轮次包住」。 + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var decided = 0; + _broker.Audit = async (_, type, _) => { + if (type == "approval/decided") { + await gate.Task; + Interlocked.Increment(ref decided); + } + }; + _broker.SetAgent("s1"); + var (_, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + + _broker.Detach(); + (await pending).Should().Be(PermissionConfirmationDecision.Deny); + + var close = _broker.CloseTurnAsync("s1"); + close.IsCompleted.Should().BeFalse("断流补写的审计未落库前,轮次收尾不得返回(decided 会落到 turn/end 之后)"); + gate.SetResult(); + await close; + decided.Should().Be(1); + } /// 构造 $events/result 请求体(DTO + JsonContext,与前端 connection.rpc.call 载荷同形状) private static JsonElement ResultArgs(string clientId, string eventId, DshEventOutcome outcome) => diff --git a/native_gui/tests/native_gui.core.tests/web_host/DshAssistantStreamTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshAssistantStreamTests.cs new file mode 100644 index 0000000000..04e3c06c7c --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/web_host/DshAssistantStreamTests.cs @@ -0,0 +1,190 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// assistant-stream 瞬态帧测试(B13,ADR 0147):hub 的 revision 密度/基线状态机, +/// 以及轮次执行器把引擎 delta 翻译成与持久 assistant/message 一致的 start/chunk/end。 +/// +public sealed class DshAssistantStreamTests { + private static JsonElement PromptArgs(string sessionId, string text) => + JsonSerializer.SerializeToElement(new { + request = new { + requestId = Guid.NewGuid().ToString("N"), + sessionId, + mode = "send", + content = new[] { new { type = "text", text } }, + }, + }); + + /// 读一条 chunk 帧的 delta 类型与文本(帧的 chunk 是 JsonElement,读之前先收窄) + private static (string Type, string Text) ReadChunk(DshAssistantStreamFrame frame) { + if (frame.Chunk is not { } chunk) { + throw new Xunit.Sdk.XunitException("chunk 帧缺 chunk 载荷(帧合成不完整)"); + } + return (chunk.GetProperty("type").GetString() ?? "", chunk.GetProperty("text").GetString() ?? ""); + } + + /// 按序读出全部 chunk 的 (类型, 文本) + private static IEnumerable<(string Type, string Text)> Chunks(IReadOnlyList frames) => + frames.Where(frame => frame.Type == "chunk").Select(ReadChunk); + + /// 帧 revision 必须严格 +1(前端强校验),基线在尝试进行/结束后如实反映状态。 + [Fact] + public async Task AssistantStream_RevisionDenseAndBaselineTracksActiveAttempt() { + var hub = new DshSessionStreamHub(); + var frames = new List(); + var baseline = hub.AssistantBaseline("s1"); + baseline.Revision.Should().Be(0, "空闲会话没有历史帧"); + baseline.ActiveAttempt.Should().BeNull(); + var (_, lease) = hub.SubscribeSession("s1", "st1", _ => Task.CompletedTask, frame => { + frames.Add(frame); + return Task.CompletedTask; + }, -1); + using (lease) { + await hub.ActivateAsync("st1"); + + await hub.StartAssistantAttemptAsync("s1", turn: 2, step: 1); + await hub.AppendAssistantChunkAsync("s1", "text-delta", 1, "你"); + await hub.AppendAssistantChunkAsync("s1", "reasoning-delta", 0, "想"); + + var live = hub.AssistantBaseline("s1"); + live.Revision.Should().Be(3); + if (live.ActiveAttempt is not { } attempt) { + throw new Xunit.Sdk.XunitException("进行中的尝试必须出现在基线里(重连重建未完成文本)"); + } + attempt.NextIndex.Should().Be(2); + attempt.Turn.Should().Be(2); + attempt.Step.Should().Be(1); + attempt.StartedAfterSeq.Should().Be(-1); + attempt.Stream.Should().HaveCount(2); + attempt.Stream[0].Chunk.GetProperty("type").GetString().Should().Be("text-delta"); + attempt.Stream[0].Chunk.GetProperty("text").GetString().Should().Be("你"); + + await hub.EndAssistantAttemptAsync("s1", new DshAssistantStreamOutcome { Kind = "committed", EventType = "assistant/message", Seq = 7 }); + + hub.AssistantBaseline("s1").Revision.Should().Be(4); + hub.AssistantBaseline("s1").ActiveAttempt.Should().BeNull("结算后不再有进行中的尝试"); + } + frames.Select(frame => frame.Revision).Should().Equal(1, 2, 3, 4); + frames.Select(frame => frame.Type).Should().Equal("start", "chunk", "chunk", "end"); + frames[0].Turn.Should().Be(2); + if (frames[3].Outcome is not { } outcome) { + throw new Xunit.Sdk.XunitException("end 帧缺 outcome"); + } + outcome.Kind.Should().Be("committed"); + outcome.Seq.Should().Be(7); + frames[3].Index.Should().Be(2, "end.index 必须等于下一条 chunk 序号(前端据此判定连续)"); + } + + /// 跨轮次 revision 不回退(重连基线 + 密度校验都依赖单调递增)。 + [Fact] + public async Task AssistantStream_SecondAttemptContinuesRevision() { + var hub = new DshSessionStreamHub(); + await hub.StartAssistantAttemptAsync("s1", 1, 1); + await hub.EndAssistantAttemptAsync("s1", new DshAssistantStreamOutcome { Kind = "abandoned" }); + var afterFirst = hub.AssistantBaseline("s1").Revision; + + await hub.StartAssistantAttemptAsync("s1", 2, 1); + + hub.AssistantBaseline("s1").Revision.Should().Be(afterFirst + 1); + hub.AssistantBaseline("s1").ActiveAttempt.Should().NotBeNull(); + } + + /// 轮次执行器:逐字帧拼接结果必须与持久助手消息的块内容逐字一致,end.seq 指向该事件。 + [Fact] + public async Task TurnRunner_StreamsDeltasConsistentWithDurableMessage() { + await using var engine = new PlaceholderChatSession(); + var (probe, dispatcher, frames) = await StartSessionAsync(engine); + + (await dispatcher.HandleAsync("session/prompt", PromptArgs(probe.SessionId, "流式验证"))).Ok.Should().BeTrue(); + await probe.TurnEnded.Task.WaitAsync(TimeSpan.FromSeconds(20)); + + var snapshot = frames.ToArray(); + snapshot.Should().NotBeEmpty("引擎有思考/正文 delta,必须产生瞬态帧"); + snapshot[0].Type.Should().Be("start"); + snapshot[^1].Type.Should().Be("end"); + if (snapshot[^1].Outcome is not { } outcome) { + throw new Xunit.Sdk.XunitException("末帧必须是带 outcome 的 end"); + } + outcome.Kind.Should().Be("committed"); + snapshot.Select(frame => frame.Revision).Should().BeInAscendingOrder().And.OnlyHaveUniqueItems(); + snapshot[^1].Index.Should().Be(snapshot.Count(frame => frame.Type == "chunk"), "end.index = chunk 帧总数"); + + var messageEvent = probe.Services.Streams.Events(probe.SessionId).Single(sessionEvent => sessionEvent.Type == "assistant/message"); + outcome.Seq.Should().Be(messageEvent.Seq); + var blocks = messageEvent.Data.GetProperty("message").GetProperty("content").EnumerateArray().ToArray(); + var chunks = Chunks(snapshot).ToArray(); + + var durableText = string.Concat(blocks.Where(block => block.GetProperty("type").GetString() == "text") + .Select(block => block.GetProperty("text").GetString())); + string.Concat(chunks.Where(chunk => chunk.Type == "text-delta").Select(chunk => chunk.Text)) + .Should().Be(durableText, "瞬态流与持久结算是同一份内容,前端替换时不得跳字"); + + var durableReasoning = string.Concat(blocks.Where(block => block.GetProperty("type").GetString() == "reasoning") + .Select(block => block.GetProperty("text").GetString())); + string.Concat(chunks.Where(chunk => chunk.Type == "reasoning-delta").Select(chunk => chunk.Text)) + .Should().Be(durableReasoning); + chunks.Length.Should().BeGreaterThan(100, "占位引擎逐字符产出,帧数应与之匹配"); + } + + /// 纯正文轮(无思考块)的正文下标必须是 0:留空洞会让前端块数组首元素缺失,流式正文不渲染。 + [Fact] + public async Task TurnRunner_TextOnlyAttemptStartsAtBlockZero() { + await using var engine = new PlaceholderChatSession { EmitThinking = false }; + var (probe, dispatcher, frames) = await StartSessionAsync(engine); + + (await dispatcher.HandleAsync("session/prompt", PromptArgs(probe.SessionId, "纯正文"))).Ok.Should().BeTrue(); + await probe.TurnEnded.Task.WaitAsync(TimeSpan.FromSeconds(20)); + + var indexes = frames.Where(frame => frame.Type == "chunk") + .Select(frame => frame.Chunk is { } chunk ? chunk.GetProperty("index").GetInt32() : -1) + .Distinct().ToArray(); + indexes.Should().BeEquivalentTo([0], "块下标按内容块出现顺序分配,首块恒为 0"); + } + + /// 瞬态帧不落会话日志(重放以 assistant/message 为准)。 + [Fact] + public async Task TurnRunner_AssistantFramesAreNotJournaled() { + await using var engine = new PlaceholderChatSession(); + var (probe, dispatcher, _) = await StartSessionAsync(engine); + + (await dispatcher.HandleAsync("session/prompt", PromptArgs(probe.SessionId, "不落盘"))).Ok.Should().BeTrue(); + await probe.TurnEnded.Task.WaitAsync(TimeSpan.FromSeconds(20)); + + probe.Services.Streams.Events(probe.SessionId) + .Should().OnlyContain(sessionEvent => sessionEvent.Type != "assistant-stream", + "瞬态帧不进日志,否则历史投影会重复渲染半截消息"); + } + + /// 建会话 + 订阅瞬态帧(订阅者本身就是被测通道的消费点) + private static async Task<(SessionProbe Probe, DshRpcDispatcher Dispatcher, ConcurrentQueue Frames)> StartSessionAsync(PlaceholderChatSession engine) { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + services.AttachEngine(engine); + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + var frames = new ConcurrentQueue(); + var turnEnded = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var (_, lease) = services.Streams.SubscribeSession( + sessionId, + "probe", + sessionEvent => { + if (sessionEvent.Type == "turn/end") { + turnEnded.TrySetResult(); + } + return Task.CompletedTask; + }, + frame => { + frames.Enqueue(frame); + return Task.CompletedTask; + }, + -1); + _ = lease; + await services.Streams.ActivateAsync("probe"); + return (new SessionProbe(services, sessionId, turnEnded), new DshRpcDispatcher(services), frames); + } + + /// 测试夹具:宿主服务 + 会话身份 + 轮次收尾信号 + private sealed record SessionProbe(DshHostServices Services, string SessionId, TaskCompletionSource TurnEnded); +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshAttachmentTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshAttachmentTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshCommandsTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshCommandsTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshCommandsTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshCommandsTests.cs diff --git a/native_gui/tests/native_gui.core.tests/web_host/DshGoalsTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshGoalsTests.cs new file mode 100644 index 0000000000..7d12b478ba --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/web_host/DshGoalsTests.cs @@ -0,0 +1,150 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// goals 七方法测试(TASK058 P2-B5):创建→CAS 编辑→pause/resume/complete 状态机(activation +/// 翻转事件)→clear;roundsStarted 经 DshTurnRunner 受理钩子递增;conflict/not-found 引导错误。 +/// +public sealed class DshGoalsTests { + private static (DshHostServices Services, DshRpcDispatcher Dispatcher) CreateServices() { + var services = new DshHostServices(new InMemoryFileSystem()); + return (services, new DshRpcDispatcher(services)); + } + + private static JsonElement GoalRefArgs(string sessionId, DshGoalRef reference) => + JsonSerializer.SerializeToElement(new { + sessionId, + @ref = new { id = reference.Id, revision = reference.Revision }, + }); + + private static JsonElement GoalCreateArgs(string sessionId, string objective, int? maxRounds = null) => + JsonSerializer.SerializeToElement(new { + sessionId, objective, maxGoalRounds = maxRounds, + }); + + [Fact] + public async Task Goals_Lifecycle_CreatePauseResumeCompleteClear() { + var (_, dispatcher) = CreateServices(); + + var created = await dispatcher.HandleAsync("goals/create", GoalCreateArgs("s1", "完成重构")); + created.Ok.Should().BeTrue(created.Error?.Message); + var reference = created.Value.GetValueOrDefault().GetProperty("ref"); + + // create 后 get 可见(armed/active/revision=1) + var got = await dispatcher.HandleAsync("goals/get", JsonSerializer.SerializeToElement(new { sessionId = "s1" })); + var goal = got.Value.GetValueOrDefault(); + goal.GetProperty("phase").GetString().Should().Be("active"); + goal.GetProperty("activation").GetString().Should().Be("armed"); + goal.GetProperty("revision").GetInt32().Should().Be(1); + goal.GetProperty("objective").GetString().Should().Be("完成重构"); + goal.GetProperty("maxGoalRounds").GetInt32().Should().Be(10, "默认轮次上限"); + + // pause → paused/disarmed/revision=2 + var paused = await dispatcher.HandleAsync("goals/pause", GoalRefArgs("s1", ParseRef(reference))); + paused.Value.GetValueOrDefault().GetProperty("activation").GetString().Should().Be("disarmed"); + + // resume → active/armed/revision=3 + var resumed = await dispatcher.HandleAsync("goals/resume", GoalRefArgs("s1", new DshGoalRef { + Id = reference.GetProperty("id").GetString() ?? "", + Revision = 2, + })); + resumed.Value.GetValueOrDefault().GetProperty("activation").GetString().Should().Be("armed"); + + // complete → complete/disarmed + var completed = await dispatcher.HandleAsync("goals/complete", GoalRefArgs("s1", new DshGoalRef { + Id = reference.GetProperty("id").GetString() ?? "", + Revision = 3, + })); + completed.Value.GetValueOrDefault().GetProperty("phase").GetString().Should().Be("complete"); + + // clear → get 返回 null + var cleared = await dispatcher.HandleAsync("goals/clear", GoalRefArgs("s1", new DshGoalRef { + Id = reference.GetProperty("id").GetString() ?? "", + Revision = 4, + })); + cleared.Ok.Should().BeTrue(cleared.Error?.Message); + var after = await dispatcher.HandleAsync("goals/get", JsonSerializer.SerializeToElement(new { sessionId = "s1" })); + after.Value.Should().NotBeNull(); + after.Value.GetValueOrDefault().ValueKind.Should().Be(JsonValueKind.Null, "清除后 goals/get 返回 null 值"); + } + + [Fact] + public async Task Goals_Edit_StaleRevision_ReturnsConflict() { + var (_, dispatcher) = CreateServices(); + var created = await dispatcher.HandleAsync("goals/create", GoalCreateArgs("s2", "目标")); + var reference = ParseRef(created.Value.GetValueOrDefault().GetProperty("ref")); + + // 正确 revision 编辑成功(revision+1) + var edited = await dispatcher.HandleAsync("goals/edit", JsonSerializer.SerializeToElement(new { + sessionId = "s2", + @ref = new { id = reference.Id, revision = reference.Revision }, + changes = new { objective = "新目标" }, + })); + edited.Ok.Should().BeTrue(edited.Error?.Message); + edited.Value.GetValueOrDefault().GetProperty("objective").GetString().Should().Be("新目标"); + + // 旧 revision 再编辑 → goals/conflict + var stale = await dispatcher.HandleAsync("goals/edit", JsonSerializer.SerializeToElement(new { + sessionId = "s2", + @ref = new { id = reference.Id, revision = reference.Revision }, + changes = new { objective = "又一次" }, + })); + stale.Ok.Should().BeFalse(); + stale.Error.Should().NotBeNull(); + if (stale.Error is { } err) { + err.Code.Should().Be("goals/conflict"); + } + } + + [Fact] + public async Task Goals_Create_Duplicate_ReturnsConflictGuidance() { + var (_, dispatcher) = CreateServices(); + await dispatcher.HandleAsync("goals/create", GoalCreateArgs("s3", "第一")); + var second = await dispatcher.HandleAsync("goals/create", GoalCreateArgs("s3", "第二")); + second.Ok.Should().BeFalse(); + if (second.Error is { } err) { + err.Code.Should().Be("goals/conflict"); + err.Message.Should().Contain("clear", "引导用户先清除再创建"); + } + } + + [Fact] + public async Task Goals_RoundsStarted_IncrementedByTurnAcceptance() { + var services = new DshHostServices(new InMemoryFileSystem()); + var dispatcher = new DshRpcDispatcher(services); + await using var transcripts = new State.TranscriptService(new InMemoryFileSystem(), "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + await dispatcher.HandleAsync("goals/create", GoalCreateArgs(sessionId, "轮次目标")); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + try { + // 两轮受理(TurnGate 挂住 StreamAsync,受理即返回) + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一轮"))).Ok.Should().BeTrue(); + hold.TrySetCanceled(); + hold = new TaskCompletionSource(); + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第二轮"))).Ok.Should().BeTrue(); + } finally { + hold.TrySetCanceled(); + } + + var got = await dispatcher.HandleAsync("goals/get", JsonSerializer.SerializeToElement(new { sessionId })); + got.Value.GetValueOrDefault().GetProperty("roundsStarted").GetInt32().Should().Be(2, "每轮受理递增"); + } + + private static DshGoalRef ParseRef(JsonElement reference) => new() { + Id = reference.GetProperty("id").GetString() ?? "", + Revision = reference.GetProperty("revision").GetInt32(), + }; + + private static JsonElement PromptArgs(string sessionId, string text) => + JsonSerializer.SerializeToElement(new { + request = new { + requestId = Guid.NewGuid().ToString("N"), + sessionId, + mode = "send", + content = new[] { new { type = "text", text } }, + }, + }); +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshModelCatalogSourceTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshModelCatalogSourceTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshModelCatalogSourceTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshModelCatalogSourceTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshModelSelectionTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshModelSelectionTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshModelSelectionTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshModelSelectionTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSessionForkTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSessionForkTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshSessionForkTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSessionForkTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSessionStoreTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSessionStoreTests.cs similarity index 99% rename from native_gui/tests/join_code_gui.tests/web_host/DshSessionStoreTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSessionStoreTests.cs index b1334644f5..d89a363434 100644 --- a/native_gui/tests/join_code_gui.tests/web_host/DshSessionStoreTests.cs +++ b/native_gui/tests/native_gui.core.tests/web_host/DshSessionStoreTests.cs @@ -425,13 +425,15 @@ public async Task Dispatcher_SessionProjections_ReturnsValuesOrNull() { [Fact] public async Task Dispatcher_SessionCreate_WithWorkspaceId_UsesWorkspacePathAndAttaches() { + // 双平台绝对路径(Linux 拼盘符前缀会撞 workspace Normalize 的 GetFullPath — CI gui-core 实证) + var wsPath = Path.GetFullPath("/demo/ws-1"); var fs = new InMemoryFileSystem(); - fs.CreateDirectory(@"C:\demo\ws-1"); + fs.CreateDirectory(wsPath); await using var transcripts = new State.TranscriptService(fs, SessionsDir); var services = new DshHostServices(fs, "/test/dsh-workspaces.json"); services.Sessions.AttachTranscripts(transcripts); var dispatcher = new DshRpcDispatcher(services); - var (workspace, _) = await services.Workspaces.CreateAsync(@"C:\demo\ws-1"); + var (workspace, _) = await services.Workspaces.CreateAsync(wsPath); var result = await dispatcher.HandleAsync("session/create", WorkspaceCreateArgs(workspace.WorkspaceId)); @@ -440,7 +442,7 @@ public async Task Dispatcher_SessionCreate_WithWorkspaceId_UsesWorkspacePathAndA ?? throw new InvalidOperationException("session/create 未返回 sessionId"); var info = await transcripts.GetSessionInfoAsync(sessionId) ?? throw new InvalidOperationException("session/create 未写出 meta.json"); - info.ProjectPath.Should().Be(@"C:\demo\ws-1"); + info.ProjectPath.Should().Be(wsPath); var reloaded = await services.Workspaces.FindAsync(workspace.WorkspaceId) ?? throw new InvalidOperationException("工作区注册表丢失"); reloaded.SessionIds.Should().Contain(sessionId); diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSettingsStorePersistenceTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSettingsStorePersistenceTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshSettingsStorePersistenceTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSettingsStorePersistenceTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSettingsStoreTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSettingsStoreTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshSettingsStoreTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSettingsStoreTests.cs diff --git a/native_gui/tests/native_gui.core.tests/web_host/DshSteerInputTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSteerInputTests.cs new file mode 100644 index 0000000000..0078c252d2 --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/web_host/DshSteerInputTests.cs @@ -0,0 +1,96 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// session/prompt mode=steer 轮内注入测试(B11,对齐 DSH agent.steer + +/// dsh-api-session-controller:运行中受理即回 accepted、不排队不开新轮;空闲时回落正常开轮; +/// 引擎未装配注入通道时诚实拒绝 session/steer-unavailable)。 +/// +public sealed class DshSteerInputTests { + private static JsonElement PromptArgs(string sessionId, string text, string mode) => + JsonSerializer.SerializeToElement(new { + request = new { + requestId = Guid.NewGuid().ToString("N"), + sessionId, + mode, + content = new[] { new { type = "text", text } }, + }, + }); + + /// 运行中轮内注入:受理、投递到引擎、不新开轮次。 + [Fact] + public async Task SteerWhileRunning_DeliversToEngine_WithoutOpeningNewTurn() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { + TurnGate = ct => hold.Task.WaitAsync(ct), + SteerSink = (_, _) => true, + }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问", "send"))).Ok.Should().BeTrue(); + services.Streams.RunningTurn(sessionId).Should().Be(1); + + var result = await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "轮内插一句", "steer")); + + result.Ok.Should().BeTrue(result.Error?.Message); + engine.LastSteer.Should().Be((sessionId, "轮内插一句")); + services.Streams.RunningTurn(sessionId).Should().Be(1, "轮内注入不得占用新轮次"); + dispatcher.ListQueue(sessionId).Should().BeEmpty("steer 不是排队语义"); + } finally { + hold.TrySetCanceled(); + } + } + + /// 空闲时 mode=steer:回落为正常开轮(对齐「没有可注入的轮次就当作新输入」)。 + [Fact] + public async Task SteerWhileIdle_FallsBackToNewTurn() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + await using var engine = new PlaceholderChatSession { SteerSink = (_, _) => true }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + + var result = await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "空闲时插入", "steer")); + + result.Ok.Should().BeTrue(result.Error?.Message); + engine.LastSteer.Should().BeNull("没有运行中的轮次,不该走注入通道"); + services.Streams.RunningTurn(sessionId).Should().Be(1, "空闲 steer 必须回落为正常开轮"); + } + + /// 引擎未装配注入邮箱:诚实拒绝并引导,不排队也不开轮。 + [Fact] + public async Task SteerWhileRunning_EngineWithoutMailbox_ReturnsSteerUnavailable() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问", "send"))).Ok.Should().BeTrue(); + + var result = await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "轮内插一句", "steer")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/steer-unavailable"); + result.Error?.Message.Should().Contain("mode=queue", "必须给出可执行的替代路径"); + dispatcher.ListQueue(sessionId).Should().BeEmpty(); + } finally { + hold.TrySetCanceled(); + } + } +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSubAgentEventProjectionTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSubAgentEventProjectionTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshSubAgentEventProjectionTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSubAgentEventProjectionTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshSubagentCatalogTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshSubagentCatalogTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshTranscriptProjectorTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshTranscriptProjectorTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshTranscriptProjectorTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshTranscriptProjectorTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshTurnEventProjectionTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshTurnEventProjectionTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshTurnEventProjectionTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshTurnEventProjectionTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshTurnRunnerTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshTurnRunnerTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshTurnRunnerTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshTurnRunnerTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshUpdateQueueTests.cs similarity index 74% rename from native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshUpdateQueueTests.cs index 43d2a6ea69..cd0959b839 100644 --- a/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs +++ b/native_gui/tests/native_gui.core.tests/web_host/DshUpdateQueueTests.cs @@ -1,8 +1,9 @@ namespace JoinCode.Gui.Tests.WebHost; /// -/// session/updateQueue 队列编辑测试(TASK058 P2-B3):mode=queue 排队项的 remove/edit 随机访问; -/// steer 返回 session/steer-unavailable(引擎无轮内注入点,诚实引导);操作不存在项返回 queue-item-not-found。 +/// session/updateQueue 队列编辑测试(TASK058 P2-B3 + B11):mode=queue 排队项的 remove/edit 随机访问; +/// steer 把排队项升级为轮内注入(引擎可收则出队投递;引擎/轮次不可收则 session/steer-unavailable +/// 且**消息留在队列**);操作不存在项返回 queue-item-not-found。 /// public sealed class DshUpdateQueueTests { private static JsonElement QueueArgs(string sessionId, string itemId, string kind, string? text = null) => @@ -71,7 +72,7 @@ public async Task UpdateQueue_Edit_RewritesQueuedText() { } [Fact] - public async Task UpdateQueue_Steer_ReturnsStableUnavailable() { + public async Task UpdateQueue_Steer_EngineWithoutMailbox_ReturnsUnavailableAndKeepsQueue() { var fs = new InMemoryFileSystem(); var services = new DshHostServices(fs); await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); @@ -91,7 +92,37 @@ public async Task UpdateQueue_Steer_ReturnsStableUnavailable() { result.Ok.Should().BeFalse(); result.Error?.Code.Should().Be("session/steer-unavailable"); - result.Error?.Message.Should().Contain("排队", "引导用户该消息将在下一轮执行"); + result.Error?.Message.Should().Contain("队列", "引导用户该消息仍在队列,会在本轮结束后顺序执行"); + dispatcher.ListQueue(sessionId).Should().ContainSingle("不可收时不得丢消息") + .Which.ItemId.Should().Be(itemId); + } finally { hold.TrySetCanceled(); } + } + + [Fact] + public async Task UpdateQueue_Steer_EngineWithMailbox_DeliversAndDequeues() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var hold = new TaskCompletionSource(); + await using var engine = new PlaceholderChatSession { + TurnGate = ct => hold.Task.WaitAsync(ct), + SteerSink = (_, _) => true, + }; + services.AttachEngine(engine); + var dispatcher = new DshRpcDispatcher(services); + try { + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue(); + (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "排队问", queue: true))).Ok.Should().BeTrue(); + var itemId = dispatcher.ListQueue(sessionId).Single().ItemId; + + var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, itemId, "steer")); + + result.Ok.Should().BeTrue(result.Error?.Message); + engine.LastSteer.Should().Be((sessionId, "排队问")); + dispatcher.ListQueue(sessionId).Should().BeEmpty("升级为注入后该行必须出队,避免下一轮重复执行"); } finally { hold.TrySetCanceled(); } } diff --git a/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceFilesChangesTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceFilesChangesTests.cs new file mode 100644 index 0000000000..d262853151 --- /dev/null +++ b/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceFilesChangesTests.cs @@ -0,0 +1,94 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// workspaceFiles/changes 流测试(TASK058 P2-B4):CreateChangeFeedAsync 产出帧 Channel +/// (ready 由宿主发;change 帧由 watcher 变更驱动,version 复用 size-mtime 合成,deleted → absent)。 +/// 用 InMemoryFileSystem.Watch 的写联动(写文件自动 NotifyChanged)做确定性验证。 +/// +public sealed class DshWorkspaceFilesChangesTests { + /// 构造带可监听文件系统的会话(cwd 下有子目录) + private static async Task<(DshHostServices Services, InMemoryFileSystem Fs, string SessionId)> CreateWatchableSessionAsync() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + await using var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + fs.CreateDirectory("/test/proj/docs"); + await fs.WriteAllTextAsync("/test/proj/readme.md", "v1\n"); + return (services, fs, sessionId); + } + + [Fact] + public async Task CreateChangeFeed_WriteFile_EmitsChangeFrameWithVersion() { + var (services, fs, sessionId) = await CreateWatchableSessionAsync(); + + var feed = await services.WorkspaceFiles.CreateChangeFeedAsync(sessionId, "docs", CancellationToken.None); + feed.Error.Should().BeNull(); + var frames = feed.Frames ?? throw new Xunit.Sdk.XunitException("Frames 缺失"); + using var lease = feed.Lease ?? throw new Xunit.Sdk.XunitException("Lease 缺失"); + + await fs.WriteAllTextAsync("/test/proj/docs/note.md", "hello\n"); + + var frame = await ReadFrameAsync(frames); + var kind = frame.GetProperty("kind").GetString(); + kind.Should().Be("change"); + var change = frame.GetProperty("change"); + change.GetProperty("absolutePath").GetString().Should().Contain("docs").And.Contain("note.md"); + change.GetProperty("version").GetString().Should().NotBeNullOrEmpty("version=size-mtime 合成"); + change.TryGetProperty("absent", out _).Should().BeFalse("创建/变更非删除"); + } + + [Fact] + public async Task CreateChangeFeed_DeleteFile_EmitsAbsentFrame() { + var (services, fs, sessionId) = await CreateWatchableSessionAsync(); + + var feed = await services.WorkspaceFiles.CreateChangeFeedAsync(sessionId, "", CancellationToken.None); + using var lease = feed.Lease ?? throw new Xunit.Sdk.XunitException("Lease 缺失"); + + fs.DeleteFile("/test/proj/readme.md"); + + var frame = await ReadFrameAsync(feed.Frames ?? throw new Xunit.Sdk.XunitException("Frames 缺失")); + var change = frame.GetProperty("change"); + change.GetProperty("absolutePath").GetString().Should().Contain("readme.md"); + change.GetProperty("absent").GetBoolean().Should().BeTrue("删除事件 → absent:true"); + } + + [Fact] + public async Task CreateChangeFeed_UnknownSession_ReturnsGuidanceError() { + var (services, _, _) = await CreateWatchableSessionAsync(); + + var feed = await services.WorkspaceFiles.CreateChangeFeedAsync("no-such-session", "", CancellationToken.None); + + feed.Error.Should().NotBeNull(); + if (feed.Error is { Error: { } detail }) { + detail.Code.Should().Be("workspaceFiles/rejected"); + } + feed.Frames.Should().BeNull(); + } + + [Fact] + public async Task CreateChangeFeed_LeaseDisposal_StopsEmission() { + var (services, fs, sessionId) = await CreateWatchableSessionAsync(); + var feed = await services.WorkspaceFiles.CreateChangeFeedAsync(sessionId, "", CancellationToken.None); + { + using var lease = feed.Lease ?? throw new Xunit.Sdk.XunitException("Lease 缺失"); + } + await fs.WriteAllTextAsync("/test/proj/new-file.md", "after dispose\n"); + + // 释放后通道应已完成(读端收到 0 项)— 确定性验证:TryRead 恒 false + var frames = feed.Frames ?? throw new Xunit.Sdk.XunitException("Frames 缺失"); + frames.Reader.TryRead(out _).Should().BeFalse("lease 释放后不再产帧"); + await frames.Reader.Completion; + } + + /// 读一帧(5s 超时 — watcher 通知经内存联动同步/异步混合,收敛等待) + private static async Task ReadFrameAsync(Channel frames) { + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + try { + return await frames.Reader.ReadAsync(cts.Token); + } catch (OperationCanceledException) { + throw new Xunit.Sdk.XunitException("5s 内未收到变更帧(watcher 联动失效)"); + } + } +} diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceFilesTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceFilesTests.cs diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceStoreTests.cs b/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceStoreTests.cs similarity index 95% rename from native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceStoreTests.cs rename to native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceStoreTests.cs index b3712ea2ba..965c0a34b5 100644 --- a/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceStoreTests.cs +++ b/native_gui/tests/native_gui.core.tests/web_host/DshWorkspaceStoreTests.cs @@ -6,8 +6,9 @@ namespace JoinCode.Gui.Tests.WebHost; /// 以及分发器 workspace/* 路由的参数校验与引导错误。 /// public sealed class DshWorkspaceStoreTests { - private const string RootA = @"C:\demo\proj-a"; - private const string RootB = @"C:\demo\proj-b"; + /// 双平台绝对路径(Linux: /demo/proj-a;Windows: 盘根 C://demo//proj-a)— CI gui-core(ubuntu) 恒可跑 + private static readonly string RootA = Path.GetFullPath("/demo/proj-a"); + private static readonly string RootB = Path.GetFullPath("/demo/proj-b"); /// 建一个带两个已存在目录的存储 private static async Task<(DshWorkspaceStore Store, InMemoryFileSystem FileSystem)> CreateStoreAsync() { diff --git a/native_gui/tests/join_code_gui.tests/webview/BridgeProtocolTests.cs b/native_gui/tests/native_gui.core.tests/webview/BridgeProtocolTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/webview/BridgeProtocolTests.cs rename to native_gui/tests/native_gui.core.tests/webview/BridgeProtocolTests.cs diff --git a/native_gui/tests/join_code_gui.tests/webview/PermissionPromptBrokerTests.cs b/native_gui/tests/native_gui.core.tests/webview/PermissionPromptBrokerTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/webview/PermissionPromptBrokerTests.cs rename to native_gui/tests/native_gui.core.tests/webview/PermissionPromptBrokerTests.cs diff --git a/native_gui/tests/join_code_gui.tests/webview/WebChatFlowTests.cs b/native_gui/tests/native_gui.core.tests/webview/WebChatFlowTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/webview/WebChatFlowTests.cs rename to native_gui/tests/native_gui.core.tests/webview/WebChatFlowTests.cs diff --git a/native_gui/tests/join_code_gui.tests/xunit.runner.json b/native_gui/tests/native_gui.core.tests/xunit.runner.json similarity index 100% rename from native_gui/tests/join_code_gui.tests/xunit.runner.json rename to native_gui/tests/native_gui.core.tests/xunit.runner.json diff --git a/native_gui/tests/join_code_gui.tests/GlobalUsings.cs b/native_gui/tests/native_gui.host.tests/GlobalUsings.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/GlobalUsings.cs rename to native_gui/tests/native_gui.host.tests/GlobalUsings.cs diff --git a/native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj b/native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj similarity index 93% rename from native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj rename to native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj index 376e02d5ef..313dc7f47f 100644 --- a/native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj +++ b/native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj @@ -3,7 +3,7 @@ test JoinCode.Gui.Tests - JoinCode.Gui.Tests + NativeGui.Host.Tests Exe @@ -14,7 +14,7 @@ - + diff --git a/native_gui/tests/join_code_gui.tests/hosting/JccChatSessionAssemblyTests.cs b/native_gui/tests/native_gui.host.tests/hosting/JccChatSessionAssemblyTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/JccChatSessionAssemblyTests.cs rename to native_gui/tests/native_gui.host.tests/hosting/JccChatSessionAssemblyTests.cs diff --git a/native_gui/tests/join_code_gui.tests/hosting/JccChatSessionPermissionSeamTests.cs b/native_gui/tests/native_gui.host.tests/hosting/JccChatSessionPermissionSeamTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/JccChatSessionPermissionSeamTests.cs rename to native_gui/tests/native_gui.host.tests/hosting/JccChatSessionPermissionSeamTests.cs diff --git a/native_gui/tests/join_code_gui.tests/hosting/JccChatSessionSubAgentTests.cs b/native_gui/tests/native_gui.host.tests/hosting/JccChatSessionSubAgentTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/JccChatSessionSubAgentTests.cs rename to native_gui/tests/native_gui.host.tests/hosting/JccChatSessionSubAgentTests.cs diff --git a/native_gui/tests/join_code_gui.tests/hosting/VisualTestApp.cs b/native_gui/tests/native_gui.host.tests/hosting/VisualTestApp.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/hosting/VisualTestApp.cs rename to native_gui/tests/native_gui.host.tests/hosting/VisualTestApp.cs diff --git a/native_gui/tests/join_code_gui.tests/theming/GuiPaletteContrastTests.cs b/native_gui/tests/native_gui.host.tests/theming/GuiPaletteContrastTests.cs similarity index 100% rename from native_gui/tests/join_code_gui.tests/theming/GuiPaletteContrastTests.cs rename to native_gui/tests/native_gui.host.tests/theming/GuiPaletteContrastTests.cs diff --git a/native_gui/tests/native_gui.host.tests/xunit.runner.json b/native_gui/tests/native_gui.host.tests/xunit.runner.json new file mode 100644 index 0000000000..dd80f43a67 --- /dev/null +++ b/native_gui/tests/native_gui.host.tests/xunit.runner.json @@ -0,0 +1,5 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json", + "parallelizeAssembly": false, + "parallelizeTestCollections": false +}