Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
9ab37b7
feat: ADR 0145 B3 CI 双平台拆分落地 | 决策: NativeGui.Core.Tests 纯 net10.0(236…
liuqihonggit Oct 21, 2026
1ed9c28
feat: TASK058 P2-B4 workspaceFiles/changes 变更流 | 决策: CreateChangeFeed…
liuqihonggit Oct 21, 2026
ae48249
docs: 建档 TASK060 DSH 剩余对齐与验收 | 决策: 先恢复 E2E 回归能力再做审批/steer 新能力
liuqihonggit Oct 21, 2026
148a6bd
docs: TASK060 B10 审批弹窗真机验收通过 | 决策: 用真实 read 工具 + settings ask 规则触发审批,…
liuqihonggit Oct 21, 2026
66ff6fc
feat: 审批审计事件落会话日志(approval/asked + approval/decided)
liuqihonggit Oct 21, 2026
c19265c
fix: 审批审计对在取消/断流路径也保持轮内闭合 | 决策: 轮次收尾顺序固化为先结清审批审计再落 turn/end(FinishTur…
liuqihonggit Oct 21, 2026
3ac0020
docs: B11 steer 轮内注入契约与 E2E 红灯证据 | 决策: 采用按会话寻址的 steer 邮箱(Actor 邮箱模型)替…
liuqihonggit Oct 21, 2026
13b5aa5
feat: steer 轮内注入打通(邮箱投递 + 步首 drain + 宿主 steer 路由)
liuqihonggit Oct 21, 2026
89a80a5
feat: TASK058 P2-B5 goals 七方法+activation-changed 事件 | 决策: DshGoalStor…
liuqihonggit Oct 21, 2026
0bb3bef
docs: ADR 0146/0147 proposed — steer 轮内注入与 DSH 终端子系统 | 决策: steer 走 St…
liuqihonggit Oct 21, 2026
fa390f5
fix: PR445 三失败修复(gui-core 平台路径 + 两 flaky 守卫)| 根因: ①DshWorkspaceStoreT…
liuqihonggit Oct 21, 2026
3d50760
fix: gui-core 漏网平台路径(DshSessionStore workspaceId 流)| 根因: 该测试 workspac…
liuqihonggit Oct 21, 2026
db30689
fix: GoalHeartbeat 异常回调测试 flaky 根治 | 根因: RegisterCallback 是 TrySend(f…
liuqihonggit Oct 21, 2026
b358ad3
feat: 网页端逐字流式瞬态帧(assistant-stream)落地 | 决策: 按 DSH 契约发 start/chunk/end …
liuqihonggit Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/ci-gui-daily.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
10 changes: 7 additions & 3 deletions .github/workflows/ci-unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions build/sln/Gui.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@
<Folder Name="/app/">
<Project Path="../../native_gui/apps/NativeGui.Host/NativeGui.Host.csproj" />
</Folder>
<Folder Name="/test/JoinCodeGui.Tests/">
<Project Path="../../native_gui/tests/join_code_gui.tests/JoinCodeGui.Tests.csproj" />
<Folder Name="/test/NativeGui.Core.Tests/">
<Project Path="../../native_gui/tests/native_gui.core.tests/NativeGui.Core.Tests.csproj" />
</Folder>
<Folder Name="/test/NativeGui.Host.Tests/">
<Project Path="../../native_gui/tests/native_gui.host.tests/NativeGui.Host.Tests.csproj" />
</Folder>
</Solution>
58 changes: 58 additions & 0 deletions docs/proposed/adr/0146-steer-in-turn-injection-mailbox.md
Original file line number Diff line number Diff line change
@@ -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<string, ConcurrentQueue<string>>`,空队列即移除键)。
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<string>`)**:语义是转发给**子代理**且只在回合构建阶段 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`(逐字流式)无关,两者独立,可分别落地。
44 changes: 44 additions & 0 deletions docs/proposed/adr/0146-steer-in-turn-injection.md
Original file line number Diff line number Diff line change
@@ -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<SteerMessage>)——宿主 `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"
56 changes: 56 additions & 0 deletions docs/proposed/adr/0147-assistant-stream-transient-frames.md
Original file line number Diff line number Diff line change
@@ -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` 是替换锚点)。
Loading