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