Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions docs/proposed/task/TASK058-DSH引擎深度对齐与DTO统一.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# TASK058 — DSH 与引擎深度对齐(相同 DTO 原则)

> ADR:[0143](../adr/architecture/0143-dsh-web-frontend-hosting.md)(延续 TASK053 M2-d/M3 未竟项)
> 目标:DSH 前端假定的宿主能力,逐项对齐到自有引擎真实数据源;宿主层直接消费引擎 DTO,消除平行存储与双源定义;GUI 作为唯一展示面。
> 执行方式:按批次推进,每批走「红测试 → 实现 → 编译 → 绿测试 → 文档 → git 提交」。

## 一、对齐差距清单(2026-10-21 双路调研结论)

### 已对齐 ✅(真引擎数据,勿动)

会话核心八件套(`list/create/rename/prompt[text+queue]/cancel/page/projections/modelCatalog`)+ workspace 全套 11 方法 + 4 流端点(`$events/session/follow/session/control/workspace/follow`)+ `directoryPicker/pick`。

### P0 — 静默断裂(功能已坏且用户不可见)

| # | 断裂点 | 现状 | 引擎闲置能力 |
|---|--------|------|--------------|
| 1 | 权限确认链 | DSH 宿主未注入 `PermissionConfirmationHandler` → `GuiPermissionConfirmationHandler.ConfirmAsync` fail-closed 静默 Deny,前端权限对话框永不弹出 | handler 已实现(旧 WebUI 消费);`approval/request` waterfall 事件白名单前端已定义 |
| 2 | 模型切换 | `session/selectModel` 未实现;`ModelSelection.Next` 恒 null;模型选择器只读 | `SetModelAsync/SetVendorAsync` 闲置 |
| 3 | 设置双源 | `DshSettingsStore` 进程内内存假 schema,重启即丢,与 settings.json 零互通 | `IConfigurationService` + `SettingsJson` DTO |
| 4 | 权限模式 | `permissionPresets/catalog` 硬编码且选择无路由;宿主三元词汇与引擎 `PermissionMode` 枚举无映射 | `SetPermissionModeAsync` |
| 5 | 流事件收窄 | `DshTurnRunner` 只消费 4/13 类:Thinking/ToolProgress/`Complete.Usage`/AgentStarted/Finished 全丢弃 | `ChatStreamEvent` 枚举齐全 |

### P1 — 功能未实现但有引擎数据可读

| # | 功能 | 现状 | 引擎闲置能力 |
|---|------|------|--------------|
| 6 | 斜杠命令 | `commands/list` 空数组、`commands/execute` 未实现 | `GetAvailableSlashCommands` + `ExecuteSlashCommandAsync` |
| 7 | 子代理 | 15+ 接口成员零暴露;`session/follow` 子代理地址显式拒绝;AgentStarted/Finished 流事件丢弃 | `GetBackgroundAgentsAsync/InterruptSubAgentAsync/…` |
| 8 | 会话管理 | `session/fork`、清空/回退无 | `ClearHistoryAsync/RewindLastTurnAsync` |
| 9 | Provider 列表 | `llm/listProviders` 空数组 | `VendorModelMap` |
| 10 | 逐字流式 | `assistant-stream` 恒 revision 0 | TASK043 已登记(引擎侧缺陷) |
| 11 | steer | `mode≠queue` 返回 `session/busy` | 引擎未暴露轮内注入(0945d7d32 前提记录) |

### P2 — 大块新功能(另行立项)
附件(fileUploads/attachment)、文件树(workspaceFiles 5)、终端(10)、goals 6、`session/updateQueue`。

### 不接 ❌(DSH 在线服务,与本项目无关)
account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAnalytics、schedule、officeToPdf —— 保持 `unimplemented` 引导错误。

## 二、DTO 统一原则(决策)

1. **宿主直接消费引擎 DTO**:设置读写走引擎 `IConfigurationService`/`SettingsJson`;权限预设以引擎 `PermissionMode` 枚举为唯一数据源(`[EnumValue]` 扩展),宿主仅做枚举→前端词汇的一次性投影,禁止反向维护第二套枚举。
2. **事件投影单点**:`ChatStreamEvent` → DSH 事件的映射只在 `DshTurnRunner`/`DshTranscriptProjector` 单点扩展,禁止在分发器散落 ad-hoc 转换。
3. **消除平行存储**:`DshSettingsStore` 内存假数据退役,`DshModelCatalogSource` 的 settings.json 直读与引擎 attach 双来源保留但补 `RefreshVendorModelMap` 联动。
4. JSON 一律 DTO + `JsonContext` 源生成(ADR 0133)。

## 三、批次计划(每批一次 commit)

| 批次 | 内容 | 状态 |
|------|------|------|
| B1 | 权限确认链闭环:注入 handler + `approval/request` waterfall + `$events/result` 回帖 + 权限预设接 `PermissionMode` 映射与 `SetPermissionModeAsync` 路由 | ✅ 单元+冒烟(选择路由落 B3 settings 统一;真机审批弹窗待手动验收) |
| B2 | 模型切换:`session/selectModel` → `SetModelAsync/SetVendorAsync` + `llm/listProviders` 接 `VendorModelMap` | ✅ 单元(引擎分支真机验收) |
| B3 | 设置统一:`DshSettingsStore` → dsh-settings.json 持久化 + permission/defaultPreset 路由 `SetPermissionModeAsync` | ✅ 单元 12/12 |
| B4 | 流事件扩展:Thinking → reasoning 块 + `Complete.Usage` → data.usage 四桶(ToolProgress 暂缓) | ✅ 单元 4/4 |
| B5 | 斜杠命令:`commands/list` 投影 [ChatCommand] 目录 + `commands/execute` 委托 SlashCommandRunner | ✅ 单元 5/5 |
| B6 | 子代理流事件:AgentStarted/Finished → tool-workflow 事件族(subagentCatalog 运行实例目录另立任务) | ✅ 单元 7/7 |
| B7 | 会话管理:`session/fork` 复制 meta/转录/journal(清空/回退无前端 RPC 消费点,不做) | ✅ 单元 3/3 |

## 四列验收表

| 基建实现 | 消费点 | 已实现 | 已验收 |
|----------|--------|--------|--------|
| B1 `DshApprovalBroker`(waterfall 帧/挂起结算/断开 fail-closed)+ `DshWebUiWindow.AttachEngineAsync` 注入 `PermissionConfirmationHandler` + `$events/result` 路由 + `permissionPresets/catalog` 枚举映射(`PermissionMode` 唯一数据源) | 前端权限对话框弹窗(approval/request)/权限预设选择器 | ✅ 单测 11/11(帧契约五键硬校验/三态 outcome/断开清理/路由引导错误/枚举投影)+ 冒烟 `$events` attach 日志 | ⬜ 真机:发触发权限确认的消息 → 审批面板弹出 → Enter 允许工具执行 |
| B2 `session/selectModel` 路由 + `ModelSelection.Next` 真值 + `llm/listProviders` | 模型选择器切换/Provider 列表 | ⬜ | ⬜ |
| B3 `DshSettingsStore` 改造读写引擎 settings.json | 前端设置页(general 等) | ⬜ | ⬜ |
| B4 `DshTurnRunner` 事件映射扩展(5 类) | 思考块/工具进度/子代理卡片/token 统计 | ⬜ | ⬜ |
| B5 `commands/list|execute` 路由 | 斜杠命令补全与执行 | ⬜ | ⬜ |
| B6 agents/interrupt/follow 子代理 | 子代理面板/轨迹 | ⬜ | ⬜ |
| B7 fork/clear/rewind | 会话菜单操作 | ⬜ | ⬜ |

## 风险与前提

- journal 双源「CLI 后写不可见」窗口:属引擎事件总线下沉课题(TASK053 已预警),本任务不处理,另行立项。
- `assistant-stream` 逐字流式依赖 TASK043(引擎流式缓冲缺陷)修复,本任务 B4 只做事件级增量。
- steer 依赖引擎暴露轮内注入接口(0945d7d32),本任务不实现。
7 changes: 7 additions & 0 deletions modules/apps/gui/hosting/IJccChatSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ public interface IJccChatSession : IAsyncDisposable {
/// </summary>
Task SetPermissionModeAsync(PermissionMode mode, CancellationToken cancellationToken = default);

/// <summary>
/// 当前权限模式 — 读引擎 IToolPermissionManager.GetCurrentModeAsync(安全闸后的实际生效值)。
/// 未注册权限系统时返回 <see cref="PermissionMode.Auto"/>(DSH 权限预设目录默认值,TASK058 B1)。
/// </summary>
Task<PermissionMode> GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(PermissionMode.Auto);

/// <summary>
/// 获取可用子代理清单 — 从 IAgentDefinitionProvider 提取全部代理定义
/// (内置 + 插件 + 用户.md + 项目.md),供 GUI @子代理补全消费。引擎未注册时返回空列表。
Expand Down
6 changes: 6 additions & 0 deletions modules/apps/gui/hosting/JccChatSession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,12 @@ public async Task SetPermissionModeAsync(PermissionMode mode, CancellationToken
await manager.SetPermissionModeAsync(mode, cancellationToken);
}

/// <inheritdoc />
public async Task<PermissionMode> GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default) {
var manager = _services.GetService<IToolPermissionManager>();
return manager is not null ? await manager.GetCurrentModeAsync(cancellationToken) : PermissionMode.Auto;
}

/// <summary>settings.json 变更转发 — theme 键变更时解析为 ThemeKind 并触发 ThemeChanged</summary>
private void OnSettingChanged(object? sender, SettingChangeEventArgs e) {
if (e.Key == ConfigKeyEnumConstants.Theme && e.NewValue is not null) {
Expand Down
71 changes: 71 additions & 0 deletions modules/apps/gui/web_host/DshAgentRunTracker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
namespace JoinCode.Gui.WebHost;

/// <summary>
/// 子代理轮内运行追踪器(TASK058 B6,翻译单点):把引擎 <c>AgentStarted/AgentFinished</c> 流事件
/// 投影为 DSH <c>tool-workflow</c> 事件族(前端 workflow-run 节点渲染子代理卡片)。
/// 每轮一个合成 run(RunId 由调用方给出);首个成员自动补发 run-start;
/// start/end 按 AgentId 配对轮内序号;未 start 的 end 静默丢弃。
/// 值域契约:outcome = completed/cancelled/failed;stopReason = completed/cancelled/error。
/// </summary>
internal sealed class DshAgentRunTracker(string runId) {
/// <summary>发布事件(type + data 树)</summary>
internal sealed record WorkflowEvent(string Type, JsonElement Data);

private readonly Dictionary<string, int> _seqByAgent = new(StringComparer.Ordinal);
private int _nextSeq;
private bool _runStarted;

/// <summary>是否已发出过 run-start(有成员加入即视为有 run)</summary>
public bool RunStarted => _runStarted;

/// <summary>处理一个 AgentStarted:首个自动先发 run-start,再发 agent-start(轮内序号 1 起)</summary>
internal List<WorkflowEvent> OnAgentStarted(ChatStreamEvent evt) {
if (string.IsNullOrEmpty(evt.AgentId)) {
return [];
}
var events = new List<WorkflowEvent>();
if (!_runStarted) {
_runStarted = true;
events.Add(new WorkflowEvent("tool-workflow/run-start", Serialize(new DshWorkflowRunStartData {
RunId = runId,
Name = "subagents",
})));
}
var seq = ++_nextSeq;
_seqByAgent[evt.AgentId] = seq;
events.Add(new WorkflowEvent("tool-workflow/agent-start", Serialize(new DshWorkflowAgentStartData {
RunId = runId,
Seq = seq,
Label = string.IsNullOrEmpty(evt.AgentName) ? evt.AgentId : evt.AgentName,
ChildId = evt.AgentId,
})));
return events;
}

/// <summary>处理一个 AgentFinished:按 AgentId 配对序号发 agent-end(成功→completed,失败/未知→failed);未 start 的丢弃</summary>
internal List<WorkflowEvent> OnAgentFinished(ChatStreamEvent evt) {
if (string.IsNullOrEmpty(evt.AgentId) || !_seqByAgent.TryGetValue(evt.AgentId, out var seq)) {
return [];
}
return [
new WorkflowEvent("tool-workflow/agent-end", Serialize(new DshWorkflowAgentEndData {
RunId = runId,
Seq = seq,
Outcome = evt.AgentSuccess is true ? "completed" : "failed",
})),
];
}

/// <summary>轮次收尾:无成员返回 null(不发空 run);stopReason 值域 completed/cancelled/error</summary>
internal WorkflowEvent? BuildRunEnd(string stopReason) =>
_runStarted
? new WorkflowEvent("tool-workflow/run-end", Serialize(new DshWorkflowRunEndData {
RunId = runId,
StopReason = stopReason,
}))
: null;

/// <summary>DTO → data 树(与 DshSessionStreamHub.Allocate 同款 GetTypeInfo 查表)</summary>
private static JsonElement Serialize<T>(T data) =>
JsonSerializer.SerializeToElement(data, DshJsonContext.Default.GetTypeInfo(typeof(T)));
}
128 changes: 128 additions & 0 deletions modules/apps/gui/web_host/DshApprovalBroker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
namespace JoinCode.Gui.WebHost;

/// <summary>
/// DSH 权限确认桥(TASK058 B1,ADR 0143 M2-d):把引擎权限确认点接到 DSH 前端 approval 对话框。
/// 链路:引擎 <c>GuiPermissionConfirmationHandler.ConfirmAsync</c> → 宿主注入委托 →
/// <c>$events</c> 流发 waterfall 帧(approval/request)→ 前端 composer 内嵌审批面板 →
/// <c>POST /api/$events/result</c> 回帖 → 本桥结算 → 决策返回引擎。
/// fail-closed 全链路:$events 未连接/已断开/监听器无人处理(next)/监听器抛错(rejected)一律拒绝。
/// 决策词汇:前端 allowed-once → Allow;rejected/其他 → Deny(前端无 always-allow 概念)。
/// </summary>
internal sealed class DshApprovalBroker {
/// <summary>一次挂起中的确认</summary>
private sealed record Pending(TaskCompletionSource<Hosting.PermissionConfirmationDecision> Decision);

private readonly System.Collections.Concurrent.ConcurrentDictionary<string, Pending> _pending = new(StringComparer.Ordinal);

/// <summary>当前 $events 连接的发送通道(null = 前端未连接/已断开);发送委托已绑定该流的 streamId</summary>
private (Func<JsonElement, Task> Send, string ClientId)? _events;

/// <summary>waterfall 帧的目标会话(轮次执行时由 DshTurnRunner 设置;缺省用 host 常量)</summary>
private volatile string? _agentId;

/// <summary>$events 流打开时挂接发送通道(连接级单例;旧连接被新连接替换)</summary>
/// <param name="send">帧值发送委托(宿主闭包绑定 streamId,保证路由到该逻辑流)</param>
/// <param name="clientId">就绪帧下发的客户端标识</param>
internal void Attach(Func<JsonElement, Task> send, string clientId) {
lock (_gate) { _events = (send, clientId); }
App.LogDiag($"[DSH-APPROVAL] events stream attached client={clientId}");
}

/// <summary>连接状态读写锁(volatile 元组不可用;读写互斥保护 _events 与 _pending 的结算一致性)</summary>
private readonly object _gate = new();

/// <summary>$events 流关闭时摘除通道:挂起中的请求立即拒绝(不挂引擎轮次),后续请求直接拒绝</summary>
internal void Detach() {
lock (_gate) { _events = null; }
foreach (var (eventId, pending) in _pending) {
if (_pending.TryRemove(eventId, out _)) {
pending.Decision.TrySetResult(Hosting.PermissionConfirmationDecision.Deny);
}
}
App.LogDiag("[DSH-APPROVAL] events stream detached: pending requests denied");
}

/// <summary>设置 waterfall 目标会话(DshTurnRunner 轮次开始时调用;前端按 agentId 路由到对应会话 scope)</summary>
internal void SetAgent(string? sessionId) => _agentId = sessionId;

/// <summary>
/// 引擎确认点入口:构造 waterfall 帧发给前端并等待回帖。
/// $events 未连接 → 立即拒绝;request 载荷只含 toolName/reason/callId(前端硬校验禁含 agent/signal)。
/// </summary>
/// <param name="request">引擎确认请求</param>
/// <returns>前端决策(allowed-once=Allow;其余一律 Deny)</returns>
internal async Task<Hosting.PermissionConfirmationDecision> RequestAsync(Hosting.PermissionConfirmationRequest request) {
var send = CurrentSend();
if (send is null) {
return Hosting.PermissionConfirmationDecision.Deny;
}

var eventId = Guid.NewGuid().ToString("N");
var frame = new DshWaterfallFrame {
Event = "approval/request",
EventId = eventId,
AgentId = _agentId ?? "host",
Request = JsonSerializer.SerializeToElement(
new DshApprovalRequestBody {
ToolName = request.ToolName,
Reason = request.ConfirmationPrompt,
CallId = request.RequestId,
}, DshJsonContext.Default.DshApprovalRequestBody),
};
var completion = new TaskCompletionSource<Hosting.PermissionConfirmationDecision>(TaskCreationOptions.RunContinuationsAsynchronously);
_pending[eventId] = new Pending(completion);
try {
await send(JsonSerializer.SerializeToElement(frame, DshJsonContext.Default.DshWaterfallFrame));
App.LogDiag($"[DSH-APPROVAL] waterfall sent event=approval/request id={eventId} tool={request.ToolName}");
} catch (Exception ex) {
_pending.TryRemove(eventId, out _);
App.LogDiag($"[DSH-APPROVAL] waterfall send failed: {ex.Message}");
return Hosting.PermissionConfirmationDecision.Deny;
}

return await completion.Task;
}

/// <summary>同步读取当前发送通道(lock 收敛在同步方法内,规避 async 方法持锁)</summary>
private Func<JsonElement, Task>? CurrentSend() {
lock (_gate) { return _events?.Send; }
}

/// <summary>
/// 前端回帖结算:kind=result 且值为 allowed-once → Allow;next/rejected/其他值 → Deny。
/// clientId 与当前 $events 连接不符(前端重连后的旧代际回帖)或未知 eventId(已结算/从未发起)返回 false(幂等,不抛错)。
/// </summary>
/// <param name="clientId">回帖声称的客户端标识(校验与当前连接一致)</param>
/// <param name="eventId">waterfall 帧的 eventId</param>
/// <param name="outcome">监听器处理结果</param>
/// <returns>是否结算了一个挂起中的请求</returns>
internal Task<bool> SettleAsync(string clientId, string eventId, DshEventOutcome? outcome) {
lock (_gate) {
if (_events is not { } channel || !string.Equals(channel.ClientId, clientId, StringComparison.Ordinal)) {
return Task.FromResult(false);
}
}
if (!_pending.TryRemove(eventId, out var pending)) {
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;
}
pending.Decision.TrySetResult(decision);
App.LogDiag($"[DSH-APPROVAL] settled id={eventId} kind={outcome?.Kind} value={outcome?.Value} -> {decision}");
return Task.FromResult(true);
}
}

/// <summary>approval/request 的前端载荷(dsh-client-ui-approval PendingApproval 消费 toolName/callId/reason/displayReason)</summary>
internal sealed class DshApprovalRequestBody {
/// <summary>工具名</summary>
public string ToolName { get; set; } = "";
/// <summary>人类可读原因(引擎确认提示文本)</summary>
public string Reason { get; set; } = "";
/// <summary>关联的调用 ID(可选)</summary>
public string? CallId { get; set; }
}
Loading