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" new file mode 100644 index 000000000..13bc1ab5a --- /dev/null +++ "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" @@ -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),本任务不实现。 diff --git a/modules/apps/gui/hosting/IJccChatSession.cs b/modules/apps/gui/hosting/IJccChatSession.cs index 0fa9866ec..87b8676ba 100644 --- a/modules/apps/gui/hosting/IJccChatSession.cs +++ b/modules/apps/gui/hosting/IJccChatSession.cs @@ -192,6 +192,13 @@ public interface IJccChatSession : IAsyncDisposable { /// Task SetPermissionModeAsync(PermissionMode mode, CancellationToken cancellationToken = default); + /// + /// 当前权限模式 — 读引擎 IToolPermissionManager.GetCurrentModeAsync(安全闸后的实际生效值)。 + /// 未注册权限系统时返回 (DSH 权限预设目录默认值,TASK058 B1)。 + /// + Task GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default) + => Task.FromResult(PermissionMode.Auto); + /// /// 获取可用子代理清单 — 从 IAgentDefinitionProvider 提取全部代理定义 /// (内置 + 插件 + 用户.md + 项目.md),供 GUI @子代理补全消费。引擎未注册时返回空列表。 diff --git a/modules/apps/gui/hosting/JccChatSession.cs b/modules/apps/gui/hosting/JccChatSession.cs index 8b371fa32..e81504e9d 100644 --- a/modules/apps/gui/hosting/JccChatSession.cs +++ b/modules/apps/gui/hosting/JccChatSession.cs @@ -246,6 +246,12 @@ public async Task SetPermissionModeAsync(PermissionMode mode, CancellationToken await manager.SetPermissionModeAsync(mode, cancellationToken); } + /// + public async Task GetCurrentPermissionModeAsync(CancellationToken cancellationToken = default) { + var manager = _services.GetService(); + return manager is not null ? await manager.GetCurrentModeAsync(cancellationToken) : PermissionMode.Auto; + } + /// settings.json 变更转发 — theme 键变更时解析为 ThemeKind 并触发 ThemeChanged private void OnSettingChanged(object? sender, SettingChangeEventArgs e) { if (e.Key == ConfigKeyEnumConstants.Theme && e.NewValue is not null) { diff --git a/modules/apps/gui/web_host/DshAgentRunTracker.cs b/modules/apps/gui/web_host/DshAgentRunTracker.cs new file mode 100644 index 000000000..3719f6b3d --- /dev/null +++ b/modules/apps/gui/web_host/DshAgentRunTracker.cs @@ -0,0 +1,71 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 子代理轮内运行追踪器(TASK058 B6,翻译单点):把引擎 AgentStarted/AgentFinished 流事件 +/// 投影为 DSH tool-workflow 事件族(前端 workflow-run 节点渲染子代理卡片)。 +/// 每轮一个合成 run(RunId 由调用方给出);首个成员自动补发 run-start; +/// start/end 按 AgentId 配对轮内序号;未 start 的 end 静默丢弃。 +/// 值域契约:outcome = completed/cancelled/failed;stopReason = completed/cancelled/error。 +/// +internal sealed class DshAgentRunTracker(string runId) { + /// 发布事件(type + data 树) + internal sealed record WorkflowEvent(string Type, JsonElement Data); + + private readonly Dictionary _seqByAgent = new(StringComparer.Ordinal); + private int _nextSeq; + private bool _runStarted; + + /// 是否已发出过 run-start(有成员加入即视为有 run) + public bool RunStarted => _runStarted; + + /// 处理一个 AgentStarted:首个自动先发 run-start,再发 agent-start(轮内序号 1 起) + internal List OnAgentStarted(ChatStreamEvent evt) { + if (string.IsNullOrEmpty(evt.AgentId)) { + return []; + } + var events = new List(); + 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; + } + + /// 处理一个 AgentFinished:按 AgentId 配对序号发 agent-end(成功→completed,失败/未知→failed);未 start 的丢弃 + internal List 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", + })), + ]; + } + + /// 轮次收尾:无成员返回 null(不发空 run);stopReason 值域 completed/cancelled/error + internal WorkflowEvent? BuildRunEnd(string stopReason) => + _runStarted + ? new WorkflowEvent("tool-workflow/run-end", Serialize(new DshWorkflowRunEndData { + RunId = runId, + StopReason = stopReason, + })) + : null; + + /// DTO → data 树(与 DshSessionStreamHub.Allocate 同款 GetTypeInfo 查表) + private static JsonElement Serialize(T data) => + JsonSerializer.SerializeToElement(data, DshJsonContext.Default.GetTypeInfo(typeof(T))); +} diff --git a/modules/apps/gui/web_host/DshApprovalBroker.cs b/modules/apps/gui/web_host/DshApprovalBroker.cs new file mode 100644 index 000000000..a4bc47bf5 --- /dev/null +++ b/modules/apps/gui/web_host/DshApprovalBroker.cs @@ -0,0 +1,128 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// DSH 权限确认桥(TASK058 B1,ADR 0143 M2-d):把引擎权限确认点接到 DSH 前端 approval 对话框。 +/// 链路:引擎 GuiPermissionConfirmationHandler.ConfirmAsync → 宿主注入委托 → +/// $events 流发 waterfall 帧(approval/request)→ 前端 composer 内嵌审批面板 → +/// POST /api/$events/result 回帖 → 本桥结算 → 决策返回引擎。 +/// fail-closed 全链路:$events 未连接/已断开/监听器无人处理(next)/监听器抛错(rejected)一律拒绝。 +/// 决策词汇:前端 allowed-once → Allow;rejected/其他 → Deny(前端无 always-allow 概念)。 +/// +internal sealed class DshApprovalBroker { + /// 一次挂起中的确认 + private sealed record Pending(TaskCompletionSource Decision); + + private readonly System.Collections.Concurrent.ConcurrentDictionary _pending = new(StringComparer.Ordinal); + + /// 当前 $events 连接的发送通道(null = 前端未连接/已断开);发送委托已绑定该流的 streamId + private (Func Send, string ClientId)? _events; + + /// waterfall 帧的目标会话(轮次执行时由 DshTurnRunner 设置;缺省用 host 常量) + private volatile string? _agentId; + + /// $events 流打开时挂接发送通道(连接级单例;旧连接被新连接替换) + /// 帧值发送委托(宿主闭包绑定 streamId,保证路由到该逻辑流) + /// 就绪帧下发的客户端标识 + internal void Attach(Func send, string clientId) { + lock (_gate) { _events = (send, clientId); } + App.LogDiag($"[DSH-APPROVAL] events stream attached client={clientId}"); + } + + /// 连接状态读写锁(volatile 元组不可用;读写互斥保护 _events 与 _pending 的结算一致性) + private readonly object _gate = new(); + + /// $events 流关闭时摘除通道:挂起中的请求立即拒绝(不挂引擎轮次),后续请求直接拒绝 + 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"); + } + + /// 设置 waterfall 目标会话(DshTurnRunner 轮次开始时调用;前端按 agentId 路由到对应会话 scope) + internal void SetAgent(string? sessionId) => _agentId = sessionId; + + /// + /// 引擎确认点入口:构造 waterfall 帧发给前端并等待回帖。 + /// $events 未连接 → 立即拒绝;request 载荷只含 toolName/reason/callId(前端硬校验禁含 agent/signal)。 + /// + /// 引擎确认请求 + /// 前端决策(allowed-once=Allow;其余一律 Deny) + internal async Task 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(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; + } + + /// 同步读取当前发送通道(lock 收敛在同步方法内,规避 async 方法持锁) + private Func? CurrentSend() { + lock (_gate) { return _events?.Send; } + } + + /// + /// 前端回帖结算:kind=result 且值为 allowed-once → Allow;next/rejected/其他值 → Deny。 + /// clientId 与当前 $events 连接不符(前端重连后的旧代际回帖)或未知 eventId(已结算/从未发起)返回 false(幂等,不抛错)。 + /// + /// 回帖声称的客户端标识(校验与当前连接一致) + /// waterfall 帧的 eventId + /// 监听器处理结果 + /// 是否结算了一个挂起中的请求 + internal Task 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); + } +} + +/// approval/request 的前端载荷(dsh-client-ui-approval PendingApproval 消费 toolName/callId/reason/displayReason) +internal sealed class DshApprovalRequestBody { + /// 工具名 + public string ToolName { get; set; } = ""; + /// 人类可读原因(引擎确认提示文本) + public string Reason { get; set; } = ""; + /// 关联的调用 ID(可选) + public string? CallId { get; set; } +} diff --git a/modules/apps/gui/web_host/DshAssistantReplyProjection.cs b/modules/apps/gui/web_host/DshAssistantReplyProjection.cs new file mode 100644 index 000000000..a1a32c074 --- /dev/null +++ b/modules/apps/gui/web_host/DshAssistantReplyProjection.cs @@ -0,0 +1,41 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 轮次事件 → DSH 助手消息投影(TASK058 B4):引擎流事件的收窄翻译单点。 +/// Thinking → content 前置 reasoning 块(前端 toAssistantBlock type=reasoning 渲染思考折叠区); +/// Complete.Usage()→ 四桶(前端 TurnUsagePanel 消费, +/// totalTokens = 四桶之和对齐前端 tokenTotal 语义)。 +/// +internal static class DshAssistantReplyProjection { + /// 把一轮的思考/正文/用量投影为前端消息块序列与用量 + /// 思考内容(null 或空表示无思考) + /// 正文(可为空) + /// 引擎 Complete 事件的用量(null 表示未报告) + /// 消息块列表与用量 DTO + internal static (List Content, DshTurnUsage? Usage) Project(string? thinking, string text, TokenUsage? usage) { + List content = []; + if (!string.IsNullOrEmpty(thinking)) { + content.Add(new DshWireContentBlock { Type = "reasoning", Text = thinking }); + } + if (!string.IsNullOrEmpty(text)) { + content.Add(new DshWireContentBlock { Type = "text", Text = text }); + } + return (content, MapUsage(usage)); + } + + /// 引擎 TokenUsage → 前端四桶(null 透传;未缓存输入 = prompt − cacheRead) + private static DshTurnUsage? MapUsage(TokenUsage? usage) { + if (usage is null) { + return null; + } + var uncachedInput = Math.Max(0, usage.PromptTokens - usage.CacheReadInputTokens); + return new DshTurnUsage { + UncachedInputTokens = uncachedInput, + OutputTokens = usage.CompletionTokens, + CacheReadTokens = usage.CacheReadInputTokens, + CacheWriteTokens = usage.CacheCreationInputTokens, + TotalTokens = uncachedInput + usage.CompletionTokens + usage.CacheReadInputTokens + usage.CacheCreationInputTokens, + ReasoningTokens = usage.ReasoningTokens, + }; + } +} diff --git a/modules/apps/gui/web_host/DshCommandsProjection.cs b/modules/apps/gui/web_host/DshCommandsProjection.cs new file mode 100644 index 000000000..215b4de3e --- /dev/null +++ b/modules/apps/gui/web_host/DshCommandsProjection.cs @@ -0,0 +1,17 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 斜杠命令元数据 → DSH 命令目录投影(TASK058 B5,翻译单点): +/// 引擎 (Name 含 / 前缀)→ 前端 /{name} 组装约定(去前缀); +/// → input.hint(补全提示)。 +/// +internal static class DshCommandsProjection { + /// 投影单条命令元数据(无参数提示时省略 input) + internal static DshCommandInfo Project(SlashCommandMetadata metadata) => new() { + Name = metadata.Name.TrimStart('/'), + Description = metadata.Description, + Input = string.IsNullOrEmpty(metadata.ArgumentHint) + ? null + : new DshCommandInputInfo { Hint = metadata.ArgumentHint }, + }; +} diff --git a/modules/apps/gui/web_host/DshHostServices.cs b/modules/apps/gui/web_host/DshHostServices.cs index 3e9cdf99f..a58685181 100644 --- a/modules/apps/gui/web_host/DshHostServices.cs +++ b/modules/apps/gui/web_host/DshHostServices.cs @@ -12,11 +12,12 @@ internal sealed class DshHostServices { /// 宿主事件日志目录(默认 ~/.jcc/dsh_journal) internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = null, string? journalDirectory = null) { Workspaces = new DshWorkspaceStore(fileSystem, workspaceFilePath); - Settings = new DshSettingsStore(); + Settings = new DshSettingsStore(fileSystem); Sessions = new DshSessionStore(); Models = new DshModelCatalogSource(); Journal = new DshSessionJournal(fileSystem, journalDirectory ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_journal")); Streams = new DshSessionStreamHub(Journal); + Approvals = new DshApprovalBroker(); } /// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误) @@ -43,4 +44,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul /// 模型目录(session/modelCatalog — 启动读 settings.json,引擎就绪后热切换) internal DshModelCatalogSource Models { get; } + + /// 权限确认桥(引擎确认点 → $events waterfall → 前端回帖,TASK058 B1) + internal DshApprovalBroker Approvals { get; } } diff --git a/modules/apps/gui/web_host/DshRpcDispatcher.cs b/modules/apps/gui/web_host/DshRpcDispatcher.cs index c0674ddb5..d85a563ce 100644 --- a/modules/apps/gui/web_host/DshRpcDispatcher.cs +++ b/modules/apps/gui/web_host/DshRpcDispatcher.cs @@ -31,18 +31,22 @@ internal DshRpcDispatcher(DshHostServices services) { ["session/prompt"] = SessionPrompt, ["session/cancel"] = Sync(args => SessionCancel(args, CancellationToken.None)), ["session/modelCatalog"] = Sync(SessionModelCatalog), + ["session/selectModel"] = SessionSelectModel, + ["session/fork"] = SessionFork, ["session/projections"] = SessionProjections, ["session/page"] = SessionPage, ["agentPresets/list"] = Sync(_ => Ok(new DshAgentPresetList { Presets = [new DshAgentPreset { Id = "standard", Order = 1, IsDefault = true }], })), - ["permissionPresets/catalog"] = Sync(_ => Ok(DefaultPermissionPresets())), + ["permissionPresets/catalog"] = PermissionPresetsCatalog, + ["$events/result"] = EventsResult, ["settings/describe"] = Sync(_ => Ok(services.Settings.Describe())), ["settings/mutate"] = Sync(args => services.Settings.Mutate(args)), ["credentials/describe"] = Sync(Credentials), - ["llm/listProviders"] = Sync(_ => Ok(new List())), + ["llm/listProviders"] = Sync(LlmListProviders), ["llm/listConfigurableProviders"] = Sync(_ => OkEmptyArray()), - ["commands/list"] = Sync(_ => OkEmptyArray()), + ["commands/list"] = Sync(CommandsList), + ["commands/execute"] = CommandsExecute, ["messageFeedback/list"] = Sync(_ => Ok(new DshMessageFeedbackListResult { Value = new DshMessageFeedbackListValue() })), ["skills/list"] = Sync(_ => Ok(new DshSkillList())), ["dynamicCordisRunner/inventory"] = Sync(_ => OkEmptyArray()), @@ -286,6 +290,86 @@ private async Task SessionPage(JsonElement args, CancellationToken /// session/modelCatalog:自有引擎的供应商/模型目录 private DshRpcResult SessionModelCatalog(JsonElement args) => Ok(_services.Models.Current); + /// + /// session/selectModel:切换会话模型(TASK058 B2)。供应商变化时先 SetVendorAsync + /// (引擎重置默认模型并持久化)再 SetModelAsync 覆盖;reasoningEffort 按引擎枚举值解析, + /// 未知值忽略不报错。响应回显实际生效的供应商/模型。 + /// + private async Task SessionSelectModel(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionSelectModelArgs)?.Request; + if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) { + return Fail("session/rejected", $"session/selectModel 需要 {{sessionId, provider, model}}(reasoningEffort 可选);收到 args={Describe(args)}"); + } + if (string.IsNullOrWhiteSpace(request.Provider) || string.IsNullOrWhiteSpace(request.Model)) { + return Fail("session/rejected", $"session/selectModel 的 provider 与 model 均为必填;收到 provider={request.Provider} model={request.Model}"); + } + if (_services.Engine is not { } engine) { + return Fail("session/not-ready", "引擎仍在组装中,稍后重试;若长时间未就绪查看 dumps/startup_timing.log"); + } + if (!string.Equals(engine.CurrentVendor, request.Provider, StringComparison.OrdinalIgnoreCase)) { + await engine.SetVendorAsync(request.Provider, cancellationToken); + } + await engine.SetModelAsync(request.Model, cancellationToken); + if (request.ReasoningEffort is { } effort && EffortLevelExtensions.FromValue(effort) is { } level) { + await engine.SetEffortLevelAsync(level, cancellationToken); + } + return Ok(new DshSessionSelectModelResult { + Selected = new DshModelChoice { + Provider = engine.CurrentVendor, + Model = engine.CurrentModelId, + ReasoningEffort = request.ReasoningEffort, + }, + }); + } + + /// + /// session/fork:分叉会话(TASK058 B7)。复制源会话 meta/转录/journal 到新 ID(v1 全量复制, + /// atSeq 截断为深化项);源会话不受影响。未知会话返回引导错误。 + /// + private async Task SessionFork(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshRequestEnvelopeDshSessionForkArgs)?.Request; + if (request is null || string.IsNullOrWhiteSpace(request.SessionId)) { + return Fail("session/rejected", $"session/fork 需要 {{sessionId, atSeq?}};收到 args={Describe(args)}"); + } + if (!_services.Sessions.IsReady) { + return Fail("session/not-ready", "会话存储尚未就绪(引擎仍在组装中),稍后重试即可"); + } + var newId = await _services.Sessions.ForkAsync(request.SessionId, _services.Journal, cancellationToken); + return newId is null + ? Fail("session/unknown", $"会话 {request.SessionId} 不在磁盘上(~/.jcc/sessions/{request.SessionId}/transcript.json 不存在);从侧栏选择一个已有会话再分叉") + : Ok(new DshSessionForkResult { SessionId = newId }); + } + + /// llm/listProviders:模型目录分组投影(与 session/modelCatalog 同源,TASK058 B2) + private DshRpcResult LlmListProviders(JsonElement args) => Ok(_services.Models.Current.Groups + .Select(group => new DshProviderInfo { Id = group.Id, Name = group.Name }) + .ToList()); + + /// commands/list:引擎斜杠命令目录投影(TASK058 B5 — 引擎 [ChatCommand] 特性为唯一数据源) + private DshRpcResult CommandsList(JsonElement args) => Ok( + (_services.Engine?.GetAvailableSlashCommands() ?? []) + .Select(DshCommandsProjection.Project) + .ToList()); + + /// + /// commands/execute:执行斜杠命令(TASK058 B5)。委托引擎 SlashCommandRunner(与 CLI/TUI 同一链路); + /// v1 不支持附件参数。成功与未知命令都带输出文本(前端即时反馈渲染)。 + /// + private async Task CommandsExecute(JsonElement args, CancellationToken cancellationToken) { + var request = args.Deserialize(DshJsonContext.Default.DshCommandsExecuteArgs); + if (request is null || string.IsNullOrWhiteSpace(request.AgentId) || string.IsNullOrWhiteSpace(request.Line)) { + return Fail("commands/rejected", $"commands/execute 需要 {{agentId, line}};收到 args={Describe(args)}(附件参数暂不支持)"); + } + if (_services.Engine is not { } engine) { + return Fail("commands/not-ready", "引擎仍在组装中,稍后重试;若长时间未就绪查看 dumps/startup_timing.log"); + } + var output = await engine.ExecuteSlashCommandAsync(request.Line, cancellationToken); + return Ok(new DshCommandsExecuteResult { + CommandId = request.Line.Split(' ', 2)[0].TrimStart('/'), + Result = new DshCommandOutcome { Kind = "success", Text = output }, + }); + } + /// credentials/describe:按环境变量判断是否已配置 private static DshRpcResult Credentials(JsonElement args) => Ok(ReadStrings(args, "refs").ToDictionary( @@ -293,18 +377,37 @@ private static DshRpcResult Credentials(JsonElement args) => name => new DshCredentialState { Configured = !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name)) }, StringComparer.Ordinal)); - /// 权限预设目录:与 DSH 默认值一致(read-only / workspace-write / danger-full-access) - private static DshPermissionPresetCatalog DefaultPermissionPresets() { - List options = [ - new() { Value = "read-only", Name = "read-only" }, - new() { Value = "workspace-write", Name = "workspace-write" }, - new() { Value = "danger-full-access", Name = "danger-full-access" }, - ]; - return new DshPermissionPresetCatalog { + /// + /// $events/result:前端对 waterfall 帧的回帖(approval 决策等)。 + /// 载荷 {clientId, eventId, outcome};结算结果对前端不可见(回帖 RPC 只需 ok), + /// 未知 eventId / clientId 不符幂等返回 ok(bridge 内部记录)。 + /// + private async Task EventsResult(JsonElement args, CancellationToken cancellationToken) { + var body = args.Deserialize(DshJsonContext.Default.DshEventsResultArgs); + if (body is null || string.IsNullOrEmpty(body.EventId) || body.Outcome is null) { + return Fail("events/result-rejected", $"$events/result 需要 {{clientId, eventId, outcome}} 三字段;收到 args={Describe(args)}"); + } + await _services.Approvals.SettleAsync(body.ClientId, body.EventId, body.Outcome); + return Ok(new DshEventsResultValue()); + } + + /// + /// 权限预设目录 — 引擎 枚举为唯一数据源(TASK058 DTO 统一原则): + /// 枚举 [EnumValue] 值即前端词汇(plan/auto/ask/bypass/unattended),默认取引擎当前模式,未就绪回退 auto。 + /// + private async Task PermissionPresetsCatalog(JsonElement args, CancellationToken cancellationToken) { + var current = _services.Engine is { } engine + ? await engine.GetCurrentPermissionModeAsync(cancellationToken) + : PermissionMode.Auto; + var options = Enum.GetValues() + .Select(mode => new DshPresetOption { Value = mode.ToValue(), Name = mode.ToValue() }) + .ToList(); + var currentName = current.ToValue(); + return Ok(new DshPermissionPresetCatalog { Options = options, DefaultOptions = options, - DefaultPreset = "workspace-write", - }; + DefaultPreset = options.Any(option => option.Value == currentName) ? currentName : PermissionMode.Auto.ToValue(), + }); } private static DshRpcResult Ok(DshSessionList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionList)); @@ -326,6 +429,8 @@ private static DshPermissionPresetCatalog DefaultPermissionPresets() { private static DshRpcResult Ok(DshPermissionPresetCatalog value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshPermissionPresetCatalog)); + private static DshRpcResult Ok(DshEventsResultValue value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshEventsResultValue)); + private static DshRpcResult Ok(DshSettingsDescribe value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSettingsDescribe)); private static DshRpcResult Ok(DshSkillList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSkillList)); @@ -344,8 +449,16 @@ private static DshPermissionPresetCatalog DefaultPermissionPresets() { private static DshRpcResult Ok(DshPinnedResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshPinnedResult)); + private static DshRpcResult Ok(DshSessionSelectModelResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionSelectModelResult)); + + private static DshRpcResult Ok(DshSessionForkResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionForkResult)); + private static DshRpcResult Ok(List value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.ListDshProviderInfo)); + private static DshRpcResult Ok(List value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.ListDshCommandInfo)); + + private static DshRpcResult Ok(DshCommandsExecuteResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshCommandsExecuteResult)); + private static DshRpcResult Ok(Dictionary value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DictionaryStringDshCredentialState)); private static DshRpcResult OkEmptyArray() => Success(JsonSerializer.SerializeToElement(Array.Empty(), DshJsonContext.Default.JsonElementArray)); diff --git a/modules/apps/gui/web_host/DshRpcProtocol.cs b/modules/apps/gui/web_host/DshRpcProtocol.cs index fb6ec2f12..e48d56b52 100644 --- a/modules/apps/gui/web_host/DshRpcProtocol.cs +++ b/modules/apps/gui/web_host/DshRpcProtocol.cs @@ -91,6 +91,98 @@ internal sealed class DshHostInfo { public string Home { get; set; } = ""; } +/// +/// $events 流上的 waterfall 帧(宿主→前端)。前端 dsh-api-gateway parseRemoteEventFrame 硬校验: +/// 必须且只能含 type/event/eventId/agentId/request 五键;request 禁含 agent/signal(前端 dispatchWaterfall 自行注入)。 +/// +internal sealed class DshWaterfallFrame { + /// 固定为 waterfall + public string Type { get; set; } = "waterfall"; + /// 事件名(如 approval/request) + public string Event { get; set; } = ""; + /// 本次请求的关联 ID(回帖 $events/result 用) + public string EventId { get; set; } = ""; + /// 目标 Agent/会话身份(前端按此路由到对应会话 scope 的监听器) + public string AgentId { get; set; } = ""; + /// 请求载荷(事件专属结构;approval 为 toolName/reason/callId?) + public JsonElement Request { get; set; } +} + +/// $events/result 回帖参数(前端→宿主,POST /api/$events/result) +internal sealed class DshEventsResultArgs { + /// $events 就绪帧下发的 clientId + public string ClientId { get; set; } = ""; + /// waterfall 帧的 eventId + public string EventId { get; set; } = ""; + /// 监听器处理结果 + public DshEventOutcome? Outcome { get; set; } +} + +/// waterfall 监听器处理结果(kind=next 表示无人处理;result 携带决策值;rejected 表示监听器抛错) +internal sealed class DshEventOutcome { + /// next / result / rejected + public string Kind { get; set; } = ""; + /// 决策值(approval 域为 allowed-once / rejected) + public JsonElement? Value { get; set; } + /// rejected 时的错误信息 + public DshEventErrorBody? Error { get; set; } +} + +/// rejected 错误体(前端 projectRemoteEventRejection 投影) +internal sealed class DshEventErrorBody { + /// 错误名 + public string Name { get; set; } = "Error"; + /// 错误消息 + public string Message { get; set; } = ""; + /// 稳定错误码(可选) + public string? Code { get; set; } +} + +/// $events/result 回帖响应(前端只看 ok,无业务载荷) +internal sealed class DshEventsResultValue; + +/// commands/list 条目(前端以 /{name} 组装命令行) +internal sealed class DshCommandInfo { + /// 命令名(无 / 前缀) + public string Name { get; set; } = ""; + /// 描述 + public string Description { get; set; } = ""; + /// 输入提示(可选) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DshCommandInputInfo? Input { get; set; } +} + +/// 命令输入提示 +internal sealed class DshCommandInputInfo { + /// 参数提示(如 model-id) + public string Hint { get; set; } = ""; +} + +/// commands/execute 参数(位置参数 wire 平铺:agentId + line;attachments v1 不支持) +internal sealed class DshCommandsExecuteArgs { + /// 目标会话(scope agent) + public string AgentId { get; set; } = ""; + /// 命令行(如 /model deepseek-v4-pro) + public string? Line { get; set; } +} + +/// commands/execute 响应 — commandId + 成败结果 +internal sealed class DshCommandsExecuteResult { + /// 命令标识 + public string CommandId { get; set; } = ""; + /// 执行结果 + public DshCommandOutcome Result { get; set; } = new(); +} + +/// 命令执行结果体(success 带输出文本,error 带提示文本) +internal sealed class DshCommandOutcome { + /// success / error + public string Kind { get; set; } = "success"; + /// 输出或错误文本(可选) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Text { get; set; } +} + /// workspace/follow 基线 internal sealed class DshWorkspaceBaseline { /// 固定为 baseline @@ -622,10 +714,68 @@ internal sealed class DshAssistantMessageData { public int Step { get; set; } /// 消息体 public DshWireMessage Message { get; set; } = new(); + /// 本轮用量(引擎 Complete.Usage 投影,未报告时整键省略,TASK058 B4) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public DshTurnUsage? Usage { get; set; } /// 流式块(历史消息为空数组) public List Stream { get; set; } = []; } +/// 本轮用量 — 前端 TurnUsagePanel 四桶(totalTokens = 四桶之和,对齐前端 tokenTotal 语义) +internal sealed class DshTurnUsage { + /// 未缓存输入 token(prompt − cacheRead) + public int UncachedInputTokens { get; set; } + /// 输出 token + public int OutputTokens { get; set; } + /// 缓存读取 token + public int CacheReadTokens { get; set; } + /// 缓存写入 token + public int CacheWriteTokens { get; set; } + /// 总 token(四桶之和) + public int TotalTokens { get; set; } + /// 推理 token(可选,未报告时整键省略) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? ReasoningTokens { get; set; } +} + +/// tool-workflow/run-start 载荷(子代理卡片创建,TASK058 B6) +internal sealed class DshWorkflowRunStartData { + /// 运行标识(宿主按轮合成 {sessionId}:{turn}:agents) + public string RunId { get; set; } = ""; + /// 运行名 + public string Name { get; set; } = ""; +} + +/// tool-workflow/agent-start 载荷(成员加入) +internal sealed class DshWorkflowAgentStartData { + /// 运行标识 + public string RunId { get; set; } = ""; + /// 轮内成员序号(start/end 配对键) + public int Seq { get; set; } + /// 显示名 + public string Label { get; set; } = ""; + /// 子代理会话 ID + public string ChildId { get; set; } = ""; +} + +/// tool-workflow/agent-end 载荷(成员定格;outcome 值域 completed/cancelled/failed) +internal sealed class DshWorkflowAgentEndData { + /// 运行标识 + public string RunId { get; set; } = ""; + /// 轮内成员序号 + public int Seq { get; set; } + /// 结果 + public string Outcome { get; set; } = ""; +} + +/// tool-workflow/run-end 载荷(stopReason 值域 completed/cancelled/error) +internal sealed class DshWorkflowRunEndData { + /// 运行标识 + public string RunId { get; set; } = ""; + /// 停止原因 + public string StopReason { get; set; } = ""; +} + /// 助手流基线(revision=0 表示无进行中的尝试) internal sealed class DshAssistantStreamBaseline { /// 流版本 @@ -665,6 +815,40 @@ internal sealed class DshModelChoice { public string? ReasoningEffort { get; set; } } +/// session/selectModel 参数 +internal sealed class DshSessionSelectModelArgs { + /// 会话 ID + public string SessionId { get; set; } = ""; + /// 供应商 + public string Provider { get; set; } = ""; + /// 模型 ID + public string Model { get; set; } = ""; + /// 推理强度(low/medium/high/max,可选) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ReasoningEffort { get; set; } +} + +/// session/selectModel 响应 — 回显实际选中的模型 +internal sealed class DshSessionSelectModelResult { + /// 选中的模型 + public DshModelChoice Selected { get; set; } = new(); +} + +/// session/fork 参数 +internal sealed class DshSessionForkArgs { + /// 源会话 ID + public string SessionId { get; set; } = ""; + /// 分叉点事件序号(可选;v1 全量复制,深化项) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public long? AtSeq { get; set; } +} + +/// session/fork 响应 — 新会话 ID +internal sealed class DshSessionForkResult { + /// 新会话 ID + public string SessionId { get; set; } = ""; +} + /// 供应商模型分组 internal sealed class DshModelGroup { /// 供应商 ID @@ -998,15 +1182,40 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(DshTurnEndData))] [JsonSerializable(typeof(DshTurnEndReason))] [JsonSerializable(typeof(DshAssistantMessageData))] +[JsonSerializable(typeof(DshTurnUsage))] +[JsonSerializable(typeof(DshWorkflowRunStartData))] +[JsonSerializable(typeof(DshWorkflowAgentStartData))] +[JsonSerializable(typeof(DshWorkflowAgentEndData))] +[JsonSerializable(typeof(DshWorkflowRunEndData))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(DshAssistantStreamBaseline))] [JsonSerializable(typeof(Dictionary))] [JsonSerializable(typeof(DshModelCatalog))] +[JsonSerializable(typeof(DshSessionSelectModelArgs))] +[JsonSerializable(typeof(DshSessionSelectModelResult))] +[JsonSerializable(typeof(DshSessionForkArgs))] +[JsonSerializable(typeof(DshSessionForkResult))] +[JsonSerializable(typeof(DshRequestEnvelope))] +[JsonSerializable(typeof(DshRequestEnvelope))] [JsonSerializable(typeof(DshAgentPresetList))] [JsonSerializable(typeof(DshPermissionPresetCatalog))] +[JsonSerializable(typeof(DshWaterfallFrame))] +[JsonSerializable(typeof(DshApprovalRequestBody))] +[JsonSerializable(typeof(DshEventsResultArgs))] +[JsonSerializable(typeof(DshEventOutcome))] +[JsonSerializable(typeof(DshEventsResultValue))] +[JsonSerializable(typeof(DshEventErrorBody))] +[JsonSerializable(typeof(DshCommandInfo))] +[JsonSerializable(typeof(DshCommandInputInfo))] +[JsonSerializable(typeof(DshCommandsExecuteArgs))] +[JsonSerializable(typeof(DshCommandsExecuteResult))] +[JsonSerializable(typeof(DshCommandOutcome))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(DshSettingsDescribe))] [JsonSerializable(typeof(DshSettingsNamespaceView))] [JsonSerializable(typeof(DshSettingsMutateArgs))] +[JsonSerializable(typeof(DshSettingsDocument))] +[JsonSerializable(typeof(DshSettingsNamespaceDocument))] [JsonSerializable(typeof(DshSchemaEnvelope))] [JsonSerializable(typeof(DshSkillList))] [JsonSerializable(typeof(DshMessageFeedbackListResult))] diff --git a/modules/apps/gui/web_host/DshSessionStore.cs b/modules/apps/gui/web_host/DshSessionStore.cs index 5b3b7be98..23049d15e 100644 --- a/modules/apps/gui/web_host/DshSessionStore.cs +++ b/modules/apps/gui/web_host/DshSessionStore.cs @@ -244,6 +244,38 @@ internal async Task> Projection return (resolvedId, DefaultAgentPreset); } + /// + /// 分叉会话(TASK058 B7):复制源会话 meta + 转录 + journal 事件到新 ID(v1 全量复制,atSeq 截断为深化项)。 + /// 源会话不受影响;新会话立即可被列表/打开/续聊。 + /// + /// 源会话 + /// 宿主事件日志(journal 事件随分叉复制) + /// 取消令牌 + /// 新会话 ID;源会话不存在或存储未就绪返回 null + internal async Task ForkAsync(string sourceSessionId, DshSessionJournal journal, CancellationToken cancellationToken) { + if (_transcripts is not { } transcripts) { + return null; + } + var sourceInfo = await transcripts.GetSessionInfoAsync(sourceSessionId, cancellationToken); + if (sourceInfo is null) { + return null; + } + var newId = Guid.NewGuid().ToString("N"); + await transcripts.EnsureSessionAsync(newId, sourceInfo with { + Id = newId, + CreatedAt = DateTime.UtcNow, + }, cancellationToken); + var entries = await transcripts.LoadTranscriptAsync(sourceSessionId, cancellationToken); + if (entries.Count > 0) { + await transcripts.AppendEntriesAsync(newId, entries, cancellationToken); + } + var events = await journal.LoadAsync(sourceSessionId, cancellationToken); + foreach (var journalEvent in events) { + await journal.AppendAsync(newId, journalEvent, cancellationToken); + } + return newId; + } + /// 投影键值(标题 / 代理预设 / 模型选择 / 列表元数据) private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList entries) { var lastPrompt = entries.LastOrDefault(entry => entry.Type is null && !entry.IsSidechain && entry.Role == "user"); diff --git a/modules/apps/gui/web_host/DshSettingsStore.cs b/modules/apps/gui/web_host/DshSettingsStore.cs index ede4c71a0..e21c8ec8b 100644 --- a/modules/apps/gui/web_host/DshSettingsStore.cs +++ b/modules/apps/gui/web_host/DshSettingsStore.cs @@ -1,61 +1,94 @@ namespace JoinCode.Gui.WebHost; /// -/// DSH 设置命名空间存储(ADR 0143)。前端把「预览版说明」等确认状态写进 -/// ui-settings-general,并立刻用 settings/describe 读回来判定成功, -/// 因此宿主必须真的落库并维护 revision。 +/// DSH 设置命名空间存储(ADR 0143,TASK058 B3 持久化改造)。前端把「预览版说明」等确认状态写进 +/// ui-settings-general,权限预设行写 permission 命名空间的 defaultPreset。 /// 值以「段路径 → JsonElement」扁平存储,读回时用 还原成嵌套对象树 /// (不手写 JSON 字符串,遵守 ADR 0133)。 -/// 当前为进程内存储(M2 再接入自有引擎的设置持久化与真实 schema)。 +/// 持久化:~/.jcc/dsh-settings.json(RelaxedJsonSerializer + JsonContext DTO,重启不丢,消除进程内假数据); +/// permission/defaultPreset 的值域以引擎 枚举 [EnumValue] 词汇为唯一数据源, +/// 写入成功即触发 (宿主路由到引擎 SetPermissionModeAsync)。 /// internal sealed class DshSettingsStore { /// 前端「预览版说明」确认所在的命名空间 private const string WelcomeNamespace = "ui-settings-general"; + /// 权限预设命名空间(前端 ui-permission-presets 写 defaultPreset) + private const string PermissionNamespace = "permission"; + + private readonly IFileSystem _fs; + private readonly string _filePath; private readonly Lock Gate = new(); private readonly Dictionary States = new(StringComparer.Ordinal) { [WelcomeNamespace] = new(), }; private readonly JsonElement JsonNull = JsonSerializer.SerializeToElement(null, DshJsonContext.Default.Object); + private bool _loaded; + + /// defaultPreset 写入成功后触发(参数为引擎 PermissionMode 枚举值字符串) + internal event Action? PermissionPresetChanged; /// - /// ui-settings-general 插件 Config 的 schema 信封,等价于前端插件里 - /// volatileForm(z.object({ welcomeNoticeVersion: z.string().volatile() })).toJSON()。 - /// 前端必须能还原并校验通过 value,否则命名空间停在 loading、提示页不渲染。 + /// 构造设置存储。 /// - private readonly JsonElement WelcomeSchema = JsonSerializer.SerializeToElement( - new DshSchemaEnvelope { - Uid = 4, - Refs = new Dictionary(StringComparer.Ordinal) { - ["3"] = new() { Type = "string" }, - ["4"] = new() { - Type = "object", - Meta = new DshSchemaMeta { Default = new DshSchemaEmptyObject() }, - Dict = new Dictionary(StringComparer.Ordinal) { ["welcomeNoticeVersion"] = 3 }, - }, - }, - }, - DshJsonContext.Default.DshSchemaEnvelope); + /// 文件系统抽象(dsh-settings.json 持久化) + /// 设置文件路径(默认 ~/.jcc/dsh-settings.json) + internal DshSettingsStore(IFileSystem fs, string? filePath = null) { + _fs = fs; + _filePath = filePath ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh-settings.json"); + } - /// 单命名空间状态 - private sealed class NamespaceState { - /// 段路径(以 / 连接)→ 值 - internal Dictionary Entries { get; } = new(StringComparer.Ordinal); - /// 写入代数(每次成功 mutate +1) - internal int Revision { get; set; } + /// + /// 启动期加载存档(幂等;文件缺失/损坏按空文档启动 — 引导错误不阻塞前端设置页)。 + /// permission 命名空间无存档值时以引擎 Auto 默认值播种。 + /// + internal async Task InitializeAsync(CancellationToken cancellationToken = default) { + if (!MarkLoaded()) { + return; + } + DshSettingsDocument? document = null; + try { + if (_fs.FileExists(_filePath)) { + var json = await _fs.ReadAllTextAsync(_filePath, cancellationToken); + document = RelaxedJsonSerializer.Deserialize(json, DshJsonContext.Default.DshSettingsDocument); + } + } catch (Exception ex) { + App.LogDiag($"[DSH-SETTINGS] load failed: {ex.Message}"); + } + ApplyLoadedDocument(document); } - /// 清空并重建为初始状态(测试与重新初始化用) - internal void Reset() { + /// 标记已加载(幂等闸门;返回是否为本轮首次调用) + private bool MarkLoaded() { lock (Gate) { - States.Clear(); - States[WelcomeNamespace] = new NamespaceState(); + if (_loaded) { + return false; + } + _loaded = true; + return true; + } + } + + /// 把加载到的文档合并进命名空间状态(无文档仅播种 permission 默认值) + private void ApplyLoadedDocument(DshSettingsDocument? document) { + lock (Gate) { + if (document is not null) { + foreach (var (ns, saved) in document.Namespaces) { + var state = States.TryGetValue(ns, out var existing) ? existing : States[ns] = new NamespaceState(); + state.Revision = saved.Revision; + foreach (var (path, value) in saved.Entries) { + state.Entries[path] = value; + } + } + } + EnsurePermissionDefaults(); } } /// settings/describe:列出全部命名空间视图 internal DshSettingsDescribe Describe() { lock (Gate) { + EnsurePermissionDefaults(); return new DshSettingsDescribe { Writable = true, HasDocument = false, @@ -66,7 +99,7 @@ internal DshSettingsDescribe Describe() { } } - /// settings/mutate:按路径应用 set/unset,返回该命名空间的新视图 + /// settings/mutate:按路径应用 set/unset,返回该命名空间的新视图;成功后落盘 internal DshRpcResult Mutate(JsonElement args) { DshSettingsMutateArgs? request; try { @@ -80,7 +113,9 @@ internal DshRpcResult Mutate(JsonElement args) { if (request.Ops is null || request.Ops.Count == 0) { return DshRpcResults.Fail("settings/rejected", $"settings/mutate 未提供 ops(ns={request.Ns})"); } + string? routedPreset = null; lock (Gate) { + EnsurePermissionDefaults(); if (!States.TryGetValue(request.Ns, out var state)) { state = new NamespaceState(); States[request.Ns] = state; @@ -91,6 +126,11 @@ internal DshRpcResult Mutate(JsonElement args) { $"settings/mutate 期望版本 {expected},实际版本 {state.Revision}(ns={request.Ns})"); } foreach (var op in request.Ops) { + if (!IsValidPermissionOp(request.Ns, op)) { + return DshRpcResults.Fail( + "settings/rejected", + $"permission/defaultPreset 的值必须是引擎枚举词汇(plan/auto/ask/bypass/unattended)之一;收到 {DescribeValue(op.Value)}"); + } if (!Apply(state, op)) { return DshRpcResults.Fail( "settings/rejected", @@ -98,7 +138,49 @@ internal DshRpcResult Mutate(JsonElement args) { } } state.Revision++; - return DshRpcResults.Ok(JsonSerializer.SerializeToElement(View(request.Ns, state), DshJsonContext.Default.DshSettingsNamespaceView)); + if (string.Equals(request.Ns, PermissionNamespace, StringComparison.Ordinal) + && state.Entries.TryGetValue("defaultPreset", out var preset) + && preset.ValueKind == JsonValueKind.String) { + routedPreset = preset.GetString(); + } + Persist(); + var view = View(request.Ns, state); + if (routedPreset is not null) { + PermissionPresetChanged?.Invoke(routedPreset); + } + return DshRpcResults.Ok(JsonSerializer.SerializeToElement(view, DshJsonContext.Default.DshSettingsNamespaceView)); + } + } + + /// 全量落盘(同步文件 IO — 设置文件 KB 级,前端 mutate 是低频交互;失败只记日志不回滚,下次写入覆盖) + private void Persist() { + try { + var directory = Path.GetDirectoryName(_filePath); + if (!string.IsNullOrEmpty(directory) && !_fs.DirectoryExists(directory)) { + _fs.CreateDirectory(directory); + } + var document = new DshSettingsDocument { + Namespaces = States.ToDictionary( + pair => pair.Key, + pair => new DshSettingsNamespaceDocument { + Revision = pair.Value.Revision, + Entries = pair.Value.Entries.ToDictionary(entry => entry.Key, entry => entry.Value.Clone(), StringComparer.Ordinal), + }, + StringComparer.Ordinal), + }; + var json = RelaxedJsonSerializer.Serialize(document, DshJsonContext.Default); + _ = _fs.WriteAllText(_filePath, json); + } catch (Exception ex) { + App.LogDiag($"[DSH-SETTINGS] persist failed: {ex.Message}"); + } + } + + /// permission 命名空间播种:无 defaultPreset 值时写入引擎 Auto 默认(与 permissionPresets/catalog 同源) + private void EnsurePermissionDefaults() { + var state = States.TryGetValue(PermissionNamespace, out var existing) ? existing : States[PermissionNamespace] = new NamespaceState(); + if (!state.Entries.ContainsKey("defaultPreset")) { + state.Entries["defaultPreset"] = JsonSerializer.SerializeToElement( + PermissionMode.Auto.ToValue(), DshJsonContext.Default.String); } } @@ -129,22 +211,81 @@ private static bool Apply(NamespaceState state, DshSettingsOp op) { } } - /// 命名空间视图(value/user 为同一棵树,base 暂为空) + /// permission 命名空间 defaultPreset 的 set 操作值域校验(引擎枚举词汇,fail-closed;其他命名空间/路径恒通过) + private static bool IsValidPermissionOp(string ns, DshSettingsOp op) { + if (!string.Equals(ns, PermissionNamespace, StringComparison.Ordinal) + || !string.Equals(op.Op, "set", StringComparison.Ordinal) + || op.Path?.Count != 1 + || !string.Equals(op.Path[0], "defaultPreset", StringComparison.Ordinal)) { + return true; + } + return op.Value is { ValueKind: JsonValueKind.String } value + && PermissionModeExtensions.FromValue(value.GetString()) is not null; + } + + /// op 值的人类可读描述(错误消息用;缺失时字面 unknown) + private static string DescribeValue(JsonElement? value) => + value is { } v ? v.ValueKind == JsonValueKind.String ? v.GetString() ?? "" : v.ValueKind.ToString() : "unknown"; + + /// 命名空间视图(value/user 为同一棵树,base 暂为空;两个已知命名空间带 schema 信封) private DshSettingsNamespaceView View(string ns, NamespaceState state) { var tree = BuildTree(state.Entries); - var isWelcome = string.Equals(ns, WelcomeNamespace, StringComparison.Ordinal); + var known = KnownSchemaFor(ns); return new DshSettingsNamespaceView { Ns = ns, - AutoGenerate = !isWelcome, + AutoGenerate = known is null, Revision = state.Revision, Value = tree, User = tree, Base = JsonNull, - Schema = isWelcome ? WelcomeSchema : JsonNull, + Schema = known ?? JsonNull, Secrets = [], }; } + /// 已知命名空间的 schema 信封(ui-settings-general / permission — 前端 configForms 硬契约,缺省停在 loading) + private JsonElement? KnownSchemaFor(string ns) => ns switch { + WelcomeNamespace => WelcomeSchema, + PermissionNamespace => PermissionSchema, + _ => null, + }; + + /// + /// ui-settings-general 插件 Config 的 schema 信封,等价于前端插件里 + /// volatileForm(z.object({ welcomeNoticeVersion: z.string().volatile() })).toJSON()。 + /// + private readonly JsonElement WelcomeSchema = JsonSerializer.SerializeToElement( + new DshSchemaEnvelope { + Uid = 4, + Refs = new Dictionary(StringComparer.Ordinal) { + ["3"] = new() { Type = "string" }, + ["4"] = new() { + Type = "object", + Meta = new DshSchemaMeta { Default = new DshSchemaEmptyObject() }, + Dict = new Dictionary(StringComparer.Ordinal) { ["welcomeNoticeVersion"] = 3 }, + }, + }, + }, + DshJsonContext.Default.DshSchemaEnvelope); + + /// + /// permission 命名空间的 schema 信封 — 同构(defaultPreset: string volatile), + /// 前端 ui-permission-presets 经 describeFace 消费 value.defaultPreset。 + /// + private readonly JsonElement PermissionSchema = JsonSerializer.SerializeToElement( + new DshSchemaEnvelope { + Uid = 4, + Refs = new Dictionary(StringComparer.Ordinal) { + ["3"] = new() { Type = "string" }, + ["4"] = new() { + Type = "object", + Meta = new DshSchemaMeta { Default = new DshSchemaEmptyObject() }, + Dict = new Dictionary(StringComparer.Ordinal) { ["defaultPreset"] = 3 }, + }, + }, + }, + DshJsonContext.Default.DshSchemaEnvelope); + /// 扁平段路径集合 → 嵌套 JSON 对象(Utf8JsonWriter,AOT 安全) private static JsonElement BuildTree(Dictionary entries) { var leaves = entries @@ -172,4 +313,26 @@ private static void WriteLevel(Utf8JsonWriter writer, (string[] Segments, JsonEl } writer.WriteEndObject(); } + + /// 单命名空间状态 + private sealed class NamespaceState { + /// 段路径(以 / 连接)→ 值 + internal Dictionary Entries { get; } = new(StringComparer.Ordinal); + /// 写入代数(每次成功 mutate +1,随文档持久化) + internal int Revision { get; set; } + } +} + +/// 宿主设置文档(dsh-settings.json 根)— 命名空间 → 扁平段路径树 +internal sealed class DshSettingsDocument { + /// 命名空间存档 + public Dictionary Namespaces { get; set; } = new(StringComparer.Ordinal); +} + +/// 单命名空间存档 — revision 与扁平段路径值 +internal sealed class DshSettingsNamespaceDocument { + /// 写入代数(重启恢复,前端 expectedRevision 续用) + public int Revision { get; set; } + /// 段路径(以 / 连接)→ 值 + public Dictionary Entries { get; set; } = new(StringComparer.Ordinal); } diff --git a/modules/apps/gui/web_host/DshTurnRunner.cs b/modules/apps/gui/web_host/DshTurnRunner.cs index edfa727ac..b6bed2836 100644 --- a/modules/apps/gui/web_host/DshTurnRunner.cs +++ b/modules/apps/gui/web_host/DshTurnRunner.cs @@ -84,6 +84,10 @@ private void StartQueued(Hosting.IJccChatSession engine, string sessionId) { /// 后台跑一轮生成:成功落助手消息,停止/失败各自落对应收尾(异常只记日志,不让进程崩) private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId, string text, int turn, CancellationToken cancellationToken) { + // 权限确认 waterfall 的目标会话(前端按 agentId 路由到对应会话 scope 的审批面板,TASK058 B1) + _services.Approvals.SetAgent(sessionId); + // 子代理轮内运行追踪(tool-workflow 事件族,TASK058 B6)— try 外声明,取消/异常分支也要收尾 + var agents = new DshAgentRunTracker($"{sessionId}:{turn}:agents"); try { engine.SwitchSession(sessionId); if (await _services.Sessions.HistoryAsync(sessionId, cancellationToken) is { Count: > 0 } history) { @@ -91,6 +95,8 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId } App.LogDiag($"[DSH-WEB] turn streaming session={sessionId} turn={turn}"); var content = new StringBuilder(); + var thinking = new StringBuilder(); + TokenUsage? usage = null; var events = 0; var calls = new List<(string CallId, string Name, string Arguments)>(); await foreach (var evt in engine.StreamAsync(text, cancellationToken)) { @@ -100,28 +106,40 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId } if (evt.Type == ChatStreamEventType.Content && !string.IsNullOrEmpty(evt.Content)) { content.Append(evt.Content); + } else if (evt.Type == ChatStreamEventType.Thinking && !string.IsNullOrEmpty(evt.ThinkingContent)) { + // 思考内容 → assistant/message 前置 reasoning 块(TASK058 B4,前端折叠渲染) + thinking.Append(evt.ThinkingContent); + } else if (evt.Type == ChatStreamEventType.Complete && evt.Usage is { } turnUsage) { + // 用量 → data.usage 四桶(前端"本轮用量"弹窗,TASK058 B4) + usage = turnUsage; + } else if (evt.Type == ChatStreamEventType.AgentStarted || evt.Type == ChatStreamEventType.AgentFinished) { + await PublishAgentEventsAsync(sessionId, agents, evt); } else if (evt.Type == ChatStreamEventType.SystemNotice) { App.LogDiag($"[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); } } - App.LogDiag($"[DSH-WEB] turn stream done session={sessionId} events={events} chars={content.Length}"); - await PublishReplyAsync(sessionId, turn, content.ToString(), engine); + App.LogDiag($"[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); + await PublishWorkflowRunEndAsync(sessionId, agents, "completed"); await EndTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "completed" }); } catch (OperationCanceledException) { App.LogDiag($"[DSH-WEB] turn cancelled session={sessionId} turn={turn}"); + await PublishWorkflowRunEndAsync(sessionId, agents, "cancelled"); await EndTurnAsync(sessionId, turn, new DshTurnEndReason { Kind = "aborted", Reason = new DshTurnEndCancelCause { Kind = "user" }, }); } catch (Exception ex) { App.LogDiag($"[DSH-WEB] turn failed session={sessionId} turn={turn}: {ex.Message}"); + await PublishWorkflowRunEndAsync(sessionId, agents, "error"); await EndTurnAsync(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); @@ -129,6 +147,28 @@ private async Task RunTurnAsync(Hosting.IJccChatSession engine, string sessionId } } + /// 发布子代理事件族(agent start/end → tool-workflow,TASK058 B6) + private async Task PublishAgentEventsAsync(string sessionId, DshAgentRunTracker agents, ChatStreamEvent evt) { + var events = evt.Type == ChatStreamEventType.AgentStarted + ? agents.OnAgentStarted(evt) + : agents.OnAgentFinished(evt); + foreach (var workflowEvent in events) { + await PublishWorkflowEventAsync(sessionId, workflowEvent); + } + } + + /// 发布 run-end 收尾(无成员时 tracker 返回 null,跳过) + private async Task PublishWorkflowRunEndAsync(string sessionId, DshAgentRunTracker agents, string stopReason) { + if (agents.BuildRunEnd(stopReason) is { } runEnd) { + await PublishWorkflowEventAsync(sessionId, runEnd); + } + } + + /// 落一条 workflow 事件(data 已是树,直接进事件流) + private async Task PublishWorkflowEventAsync(string sessionId, DshAgentRunTracker.WorkflowEvent workflowEvent) { + await _services.Streams.PublishAsync(sessionId, workflowEvent.Type, workflowEvent.Data); + } + /// 落一次工具调用或工具结果事件(callId 缺失时按序号补齐,保证调用与结果可配对) private async Task PublishToolCallAsync(string sessionId, int turn, ChatStreamEvent evt, List<(string CallId, string Name, string Arguments)> calls) { var callId = string.IsNullOrEmpty(evt.ToolCallId) ? $"{sessionId}:{turn}:tool:{calls.Count + 1}" : evt.ToolCallId; @@ -160,24 +200,27 @@ private async Task PublishToolCallAsync(string sessionId, int turn, ChatStreamEv }, surfaceOp: "append"); } - /// 落助手回复事件(空回复不落,避免空气泡) - private async Task PublishReplyAsync(string sessionId, int turn, string reply, Hosting.IJccChatSession engine) { - if (!string.IsNullOrWhiteSpace(reply)) { - await _services.Streams.PublishAsync(sessionId, "assistant/message", new DshAssistantMessageData { - Turn = turn, - Step = Step, - Message = new DshWireMessage { - Id = $"{sessionId}:{turn}:assistant", - Role = "assistant", - Content = [new DshWireContentBlock { Text = reply }], - Source = new DshWireMessageSource { - Kind = "model", - Provider = engine.CurrentVendor, - Model = engine.CurrentModelId, - }, - }, - }, surfaceOp: "append"); + /// 落助手回复事件(空块序列不落,避免空气泡;思考/正文/用量经投影单点翻译,TASK058 B4) + 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; } + await _services.Streams.PublishAsync(sessionId, "assistant/message", new DshAssistantMessageData { + Turn = turn, + Step = Step, + Message = new DshWireMessage { + Id = $"{sessionId}:{turn}:assistant", + Role = "assistant", + Content = content, + Source = new DshWireMessageSource { + Kind = "model", + Provider = engine.CurrentVendor, + Model = engine.CurrentModelId, + }, + }, + Usage = mappedUsage, + }, surfaceOp: "append"); } /// 落步骤结束与轮次结束(先 step/end 再 turn/end) diff --git a/modules/apps/gui/web_host/DshWebHost.cs b/modules/apps/gui/web_host/DshWebHost.cs index 7e7b64388..24cad9ebc 100644 --- a/modules/apps/gui/web_host/DshWebHost.cs +++ b/modules/apps/gui/web_host/DshWebHost.cs @@ -210,11 +210,16 @@ private static async Task SendItemAsync(WebSocket socket, SemaphoreSlim gate, Ds DshStreamOpen open, Func send, CancellationToken cancellationToken) { App.LogDiag($"[DSH-WEB] stream open endpoint={open.Endpoint} id={open.StreamId}"); switch (open.Endpoint) { - case "$events": + case "$events": { + // ready 首帧 + 挂接权限确认桥(后续 waterfall 帧走同一条流;连接关闭时 fail-closed 清挂起请求) + var clientId = Guid.NewGuid().ToString(); + Func sendFrame = frame => send(Item(open.StreamId, frame)); + _services.Approvals.Attach(sendFrame, clientId); return ([Item(open.StreamId, JsonSerializer.SerializeToElement(new DshEventsReady { - ClientId = Guid.NewGuid().ToString(), + ClientId = clientId, Host = new DshHostInfo { Home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) }, - }, DshJsonContext.Default.DshEventsReady))], null, null); + }, DshJsonContext.Default.DshEventsReady))], new DetachLease(() => _services.Approvals.Detach()), null); + } case "workspace/follow": { var baseline = await _services.Workspaces.ReadBaselineAsync(cancellationToken); return ([Item(open.StreamId, JsonSerializer.SerializeToElement( @@ -287,6 +292,12 @@ private static JsonElement StreamError(string code, string message) => private static DshStreamItem Item(string streamId, JsonElement value) => new() { StreamId = streamId, Value = value }; + /// 流连接关闭时的清理动作句柄($events 摘除权限桥通道等) + private sealed record DetachLease(Action Detach) : IDisposable { + /// 执行清理(幂等由被调方保证) + public void Dispose() => Detach(); + } + /// 读取一条完整文本消息(分片聚合),连接关闭/空消息返回 null private static async Task ReceiveTextAsync(WebSocket socket, byte[] buffer) { var builder = new StringBuilder(); diff --git a/modules/apps/gui/web_host/DshWebUiWindow.cs b/modules/apps/gui/web_host/DshWebUiWindow.cs index 1d4bd689a..7b5878257 100644 --- a/modules/apps/gui/web_host/DshWebUiWindow.cs +++ b/modules/apps/gui/web_host/DshWebUiWindow.cs @@ -27,11 +27,29 @@ internal DshWebUiWindow(string userDataDir) { _view.InitFailed += ShowFallback; Content = _view; DshDirectoryPicker.Register(PickFolderAsync); + _ = _services.Settings.InitializeAsync(CancellationToken.None); + // 权限预设写路径 → 引擎 SetPermissionModeAsync 立即生效(TASK058 B3) + _services.Settings.PermissionPresetChanged += OnPermissionPresetChanged; _ = LoadModelCatalogAsync(); _ = AttachEngineAsync(); App.LogDiag($"[DSH-WEB] window created url={_host.BaseUrl}"); } + /// 权限预设变更路由:settings/mutate 写 defaultPreset 成功后应用到引擎(未就绪只记日志,引擎 attach 时读存档补应用) + private void OnPermissionPresetChanged(string preset) { + if (PermissionModeExtensions.FromValue(preset) is not { } mode) { + App.LogDiag($"[DSH-WEB] permission preset ignored unknown value: {preset}"); + return; + } + if (_engine is not { } engine) { + App.LogDiag($"[DSH-WEB] permission preset deferred until engine ready: {preset}"); + return; + } + _ = engine.SetPermissionModeAsync(mode).ContinueWith( + task => App.LogDiag($"[DSH-WEB] permission preset applied={preset} failed={task.Exception?.Message}"), + TaskScheduler.Default); + } + /// /// 启动期读 settings.json 构建模型目录(引擎未就绪也能渲染模型选择),引擎 attach 后热切换成引擎真值。 /// 读取失败只让目录为空,不影响前端渲染。 @@ -48,12 +66,15 @@ private async Task LoadModelCatalogAsync() { /// /// 后台组装自有引擎,就绪后把会话存储交给宿主(渐进式:窗口先显示,数据后到)。 /// 引擎失败只影响数据,Web UI 仍可用(宿主方法返回空结果 + 引导错误)。引擎随窗口关闭释放。 + /// 权限确认链(TASK058 B1):注入 PermissionConfirmationHandler — 引擎确认点经 DshApprovalBridge + /// 发 $events waterfall 帧给前端审批面板,回帖结算后返回决策(前端未连接/断开一律 fail-closed Deny)。 /// private async Task AttachEngineAsync() { try { var engine = await Hosting.JccChatSession.CreateAsync(); _engine = engine; _services.AttachEngine(engine); + engine.PermissionConfirmationHandler = request => _services.Approvals.RequestAsync(request); if (engine.TranscriptService is { } transcripts) { _services.Sessions.AttachTranscripts(transcripts); App.LogDiag("[DSH-WEB] engine attached: transcripts ready"); @@ -61,12 +82,29 @@ private async Task AttachEngineAsync() { App.LogDiag("[DSH-WEB] engine ready but ITranscriptService unavailable"); } _services.Models.AttachEngine(engine.CurrentVendor, engine.CurrentModelId, engine.VendorModelMap); + await ApplyStoredPermissionPresetAsync(engine); App.LogDiag($"[DSH-WEB] engine model catalog: vendor={engine.CurrentVendor} model={engine.CurrentModelId} groups={engine.VendorModelMap.Count}"); } catch (Exception ex) { App.LogDiag($"[DSH-WEB] engine attach failed: {ex.Message}"); } } + /// 引擎就绪后补应用存档的权限预设(用户上次会话保存的 defaultPreset,TASK058 B3) + private async Task ApplyStoredPermissionPresetAsync(Hosting.IJccChatSession engine) { + try { + var describe = _services.Settings.Describe(); + var view = describe.Namespaces.FirstOrDefault(ns => string.Equals(ns.Ns, "permission", StringComparison.Ordinal)); + if (view?.Value.TryGetProperty("defaultPreset", out var preset) is true + && preset.ValueKind == JsonValueKind.String + && PermissionModeExtensions.FromValue(preset.GetString()) is { } mode) { + await engine.SetPermissionModeAsync(mode); + App.LogDiag($"[DSH-WEB] stored permission preset applied: {preset.GetString()}"); + } + } catch (Exception ex) { + App.LogDiag($"[DSH-WEB] stored permission preset apply failed: {ex.Message}"); + } + } + /// 前端 directoryPicker/pick 的原生实现:UI 线程弹目录选择对话框,取消返回 null private async Task PickFolderAsync(CancellationToken cancellationToken) { var folders = await Dispatcher.UIThread.InvokeAsync(() => StorageProvider.OpenFolderPickerAsync(new Avalonia.Platform.Storage.FolderPickerOpenOptions { diff --git a/test/unit/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs b/test/unit/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs new file mode 100644 index 000000000..6c3e9631a --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshApprovalBrokerTests.cs @@ -0,0 +1,191 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// DSH 权限确认桥测试(TASK058 B1):引擎确认点 → $events waterfall 帧 → 前端回帖 → 决策返回的完整闭环契约。 +/// 帧形状契约(前端 dsh-api-gateway parseRemoteEventFrame 硬校验): +/// waterfall 帧必须且只能含 type/event/eventId/agentId/request 五键,request 禁含 agent/signal。 +/// +public sealed class DshApprovalBrokerTests { + /// 被测桥(每测试独立实例,避免挂起请求串味) + private readonly DshApprovalBroker _broker = new(); + + /// 引擎侧确认请求(与 GuiPermissionConfirmationHandler 转发载荷同形状) + private static PermissionConfirmationRequest EngineRequest(string tool = "Bash") => + new(tool, "权限确认: [黄灯ask] 执行 shell 命令", RequestId: "req-1", RuleContent: null); + + /// 构造前端回帖 outcome(与 $events/result 请求体同形状) + private static DshEventOutcome Outcome(string kind, string? value = null) => new() { + Kind = kind, + Value = value is null ? null : JsonSerializer.SerializeToElement(value, DshJsonContext.Default.String), + }; + + /// attach 一个捕获发送帧的假 $events 连接(信号量通知首帧到达,替代盲等轮询) + private (List Sent, SemaphoreSlim Signal) AttachCapture() { + var sent = new List(); + var signal = new SemaphoreSlim(0, int.MaxValue); + _broker.Attach(frame => { + lock (sent) { sent.Add(frame); } + signal.Release(); + return Task.CompletedTask; + }, "client-1"); + return (sent, signal); + } + + /// 发起一次确认并在首帧发出后完成回帖,返回决策 + private async Task RequestThenSettleAsync(DshEventOutcome outcome) { + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + await _broker.SettleAsync("client-1", FrameEventId(sent), outcome); + return await pending; + } + + /// 等待首帧信号(上限 2 秒,超时即测试失败) + private static async Task WaitFrameAsync(SemaphoreSlim signal) { + (await signal.WaitAsync(TimeSpan.FromSeconds(2))).Should().BeTrue("waterfall 帧未在 2 秒内发出"); + } + + /// 取首帧的 eventId + private static string FrameEventId(List sent) { + lock (sent) { return sent[0].GetProperty("eventId").GetString() ?? string.Empty; } + } + + /// 取首帧快照(避免与捕获回调并发读写) + private static JsonElement FirstFrame(List sent) { + lock (sent) { return sent[0]; } + } + + [Fact] + public async Task Request_WithoutEventsStream_DeniesImmediately() { + // $events 未连接(引擎先于前端就绪/前端已断开)→ fail-closed 拒绝,不挂起引擎轮次 + var decision = await _broker.RequestAsync(EngineRequest()); + decision.Should().Be(PermissionConfirmationDecision.Deny); + } + + [Fact] + public async Task Request_EmitsWaterfallFrame_WithExactContractShape() { + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + try { + var frame = FirstFrame(sent); + frame.GetProperty("type").GetString().Should().Be("waterfall"); + frame.GetProperty("event").GetString().Should().Be("approval/request"); + frame.GetProperty("eventId").GetString().Should().NotBeNullOrEmpty(); + frame.GetProperty("agentId").GetString().Should().NotBeNullOrEmpty(); + var request = frame.GetProperty("request"); + request.GetProperty("toolName").GetString().Should().Be("Bash"); + request.GetProperty("reason").GetString().Should().Contain("权限确认"); + // 前端硬校验:request 不得携带 agent/signal 键(前端自己注入) + request.EnumerateObject().Select(p => p.Name).Should().NotContain("agent").And.NotContain("signal"); + frame.EnumerateObject().Select(p => p.Name).Should().BeEquivalentTo(["type", "event", "eventId", "agentId", "request"]); + } finally { + await _broker.SettleAsync("client-1", FrameEventId(sent), Outcome("result", "rejected")); + await pending; + } + } + + [Fact] + public async Task Settle_AllowedOnce_ReturnsAllow() => + (await RequestThenSettleAsync(Outcome("result", "allowed-once"))).Should().Be(PermissionConfirmationDecision.Allow); + + [Fact] + public async Task Settle_RejectedValue_ReturnsDeny() => + (await RequestThenSettleAsync(Outcome("result", "rejected"))).Should().Be(PermissionConfirmationDecision.Deny); + + [Fact] + public async Task Settle_NextOutcome_FailsClosedAsDeny() => + // 无人处理的 waterfall(kind=next)→ fail-closed 拒绝 + (await RequestThenSettleAsync(Outcome("next"))).Should().Be(PermissionConfirmationDecision.Deny); + + [Fact] + public async Task Settle_RejectedOutcome_FailsClosedAsDeny() => + // 监听器抛错(kind=rejected)→ fail-closed 拒绝 + (await RequestThenSettleAsync(new DshEventOutcome { Kind = "rejected", Error = new DshEventErrorBody { Name = "Error", Message = "boom" } })) + .Should().Be(PermissionConfirmationDecision.Deny); + + [Fact] + public async Task Settle_UnknownEventId_ReturnsFalse() { + 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; + (await _broker.SettleAsync("client-1", "no-such-event", Outcome("result", "allowed-once"))).Should().BeFalse(); + } + + [Fact] + public async Task Detach_FailsClosed_DeniesPendingAndFuture() { + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + _broker.Detach(); + // 断开时挂起中的请求立即拒绝(不挂引擎轮次) + (await pending).Should().Be(PermissionConfirmationDecision.Deny); + // 之后的新请求也直接拒绝 + _broker.RequestAsync(EngineRequest()).IsCompleted.Should().BeTrue(); + (await _broker.RequestAsync(EngineRequest())).Should().Be(PermissionConfirmationDecision.Deny); + } + + [Fact] + public async Task Settle_SecondReplyIsIgnored_PendingAlreadySettled() { + var (sent, signal) = AttachCapture(); + var pending = _broker.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + var eventId = FrameEventId(sent); + await _broker.SettleAsync("client-1", eventId, Outcome("result", "allowed-once")); + (await pending).Should().Be(PermissionConfirmationDecision.Allow); + // 重复回帖幂等:不抛异常,返回 false + (await _broker.SettleAsync("client-1", eventId, Outcome("result", "rejected"))).Should().BeFalse(); + } + + // ---- DshRpcDispatcher 路由($events/result 与 permissionPresets/catalog)---- + + /// 构造 $events/result 请求体(DTO + JsonContext,与前端 connection.rpc.call 载荷同形状) + private static JsonElement ResultArgs(string clientId, string eventId, DshEventOutcome outcome) => + JsonSerializer.SerializeToElement( + new DshEventsResultArgs { ClientId = clientId, EventId = eventId, Outcome = outcome }, + DshJsonContext.Default.DshEventsResultArgs); + + [Fact] + public async Task Dispatcher_EventsResult_RoutesSettleAndRejectsMalformedArgs() { + var services = new DshHostServices(new IO.FileSystem.InMemoryFileSystem()); + var dispatcher = new DshRpcDispatcher(services); + var sent = new ConcurrentQueue(); + using var signal = new SemaphoreSlim(0, int.MaxValue); + services.Approvals.Attach(frame => { + sent.Enqueue(frame); + signal.Release(); + return Task.CompletedTask; + }, "client-1"); + + var pending = services.Approvals.RequestAsync(EngineRequest()); + await WaitFrameAsync(signal); + sent.TryDequeue(out var first); + var eventId = first.GetProperty("eventId").GetString() ?? string.Empty; + + // 正常回帖:ok=true,请求结算为 Allow + var ok = await dispatcher.HandleAsync("$events/result", ResultArgs("client-1", eventId, Outcome("result", "allowed-once"))); + ok.Ok.Should().BeTrue(ok.Error?.Message); + (await pending).Should().Be(PermissionConfirmationDecision.Allow); + + // 畸形载荷:稳定引导错误 + var bad = await dispatcher.HandleAsync("$events/result", JsonDocument.Parse("{}").RootElement.Clone()); + bad.Ok.Should().BeFalse(); + bad.Error?.Code.Should().Be("events/result-rejected"); + } + + [Fact] + public async Task Dispatcher_PermissionPresets_UsesEngineModeEnumAsSingleSource() { + var dispatcher = new DshRpcDispatcher(new DshHostServices(new IO.FileSystem.InMemoryFileSystem())); + + var result = await dispatcher.HandleAsync("permissionPresets/catalog", default(JsonElement)); + + result.Ok.Should().BeTrue(result.Error?.Message); + var catalog = result.Value.GetValueOrDefault(); + var values = catalog.GetProperty("options").EnumerateArray().Select(o => o.GetProperty("value").GetString()).ToList(); + // 引擎 PermissionMode 枚举 [EnumValue] 值是唯一数据源(plan/auto/ask/bypass/unattended),硬编码三元组已退役 + values.Should().BeEquivalentTo(["plan", "auto", "ask", "bypass", "unattended"]); + catalog.GetProperty("defaultPreset").GetString().Should().Be("auto"); + } +} diff --git a/test/unit/join_code_gui.tests/web_host/DshCommandsTests.cs b/test/unit/join_code_gui.tests/web_host/DshCommandsTests.cs new file mode 100644 index 000000000..e6aaf6707 --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshCommandsTests.cs @@ -0,0 +1,64 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// DSH 斜杠命令路由测试(TASK058 B5):commands/list 参数契约与元数据投影翻译。 +/// 引擎执行分支(SlashCommandRunner)由真机验收覆盖。 +/// +public sealed class DshCommandsTests { + /// 构造 commands/execute 请求(位置参数 wire 平铺:agentId + line,DTO + JsonContext) + private static JsonElement ExecuteArgs(string? agentId, string? line) => + JsonSerializer.SerializeToElement( + new DshCommandsExecuteArgs { AgentId = agentId ?? "", Line = line }, + DshJsonContext.Default.DshCommandsExecuteArgs); + + /// 无引擎测试分发器 + private static DshRpcDispatcher NewDispatcher() => + new(new DshHostServices(new InMemoryFileSystem())); + + [Fact] + public async Task CommandsList_WithoutEngine_ReturnsEmptyStableOk() { + var result = await NewDispatcher().HandleAsync("commands/list", default(JsonElement)); + + result.Ok.Should().BeTrue(result.Error?.Message); + result.Value.GetValueOrDefault().GetArrayLength().Should().Be(0); + } + + [Fact] + public async Task CommandsExecute_MissingLine_RejectsWithGuidance() { + var result = await NewDispatcher().HandleAsync("commands/execute", ExecuteArgs("s1", null)); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("commands/rejected"); + } + + [Fact] + public async Task CommandsExecute_EngineNotReady_ReturnsStableNotReadyError() { + var result = await NewDispatcher().HandleAsync("commands/execute", ExecuteArgs("s1", "/model deepseek-v4-pro")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("commands/not-ready"); + } + + [Fact] + public void Projection_StripsLeadingSlash_AndMapsHint() { + var metadata = new SlashCommandMetadata { + Name = "/model", + Description = "切换模型", + ArgumentHint = "model-id", + }; + + var info = DshCommandsProjection.Project(metadata); + + info.Name.Should().Be("model", "前端以 /${name} 组装命令行,宿主去掉前缀"); + info.Description.Should().Be("切换模型"); + var input = info.Input.Should().BeOfType().Subject; + input.Hint.Should().Be("model-id"); + } + + [Fact] + public void Projection_NoHint_OmitsInput() { + var info = DshCommandsProjection.Project(new SlashCommandMetadata { Name = "/clear", Description = "清空" }); + + info.Input.Should().BeNull(); + } +} diff --git a/test/unit/join_code_gui.tests/web_host/DshModelSelectionTests.cs b/test/unit/join_code_gui.tests/web_host/DshModelSelectionTests.cs new file mode 100644 index 000000000..75e59fd2a --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshModelSelectionTests.cs @@ -0,0 +1,74 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// DSH 模型切换路由测试(TASK058 B2):session/selectModel 参数契约与 llm/listProviders 目录投影。 +/// 引擎分支(SetVendorAsync/SetModelAsync/SetEffortLevelAsync)由真机验收覆盖(接口成员过多无法轻量替身)。 +/// +public sealed class DshModelSelectionTests { + /// 构造 session/selectModel 请求(具名参数 request 信封,与前端 sessions.selectModel 调用同形状) + private static JsonElement SelectModelArgs(string? sessionId, string? provider, string? model, string? effort = null) => + JsonSerializer.SerializeToElement( + new DshRequestEnvelope { + Request = new() { + SessionId = sessionId ?? "", + Provider = provider ?? "", + Model = model ?? "", + ReasoningEffort = effort, + }, + }, DshJsonContext.Default.DshRequestEnvelopeDshSessionSelectModelArgs); + + /// 无引擎的测试分发器(引擎分支走真机) + private static DshRpcDispatcher NewDispatcher(DshHostServices? services = null) => + new(services ?? new DshHostServices(new IO.FileSystem.InMemoryFileSystem())); + + [Fact] + public async Task SelectModel_MissingProviderOrModel_RejectsWithGuidance() { + var dispatcher = NewDispatcher(); + + var noProvider = await dispatcher.HandleAsync("session/selectModel", SelectModelArgs("s1", "", "m1")); + noProvider.Ok.Should().BeFalse(); + noProvider.Error?.Code.Should().Be("session/rejected"); + + var noModel = await dispatcher.HandleAsync("session/selectModel", SelectModelArgs("s1", "deepseek", "")); + noModel.Error?.Code.Should().Be("session/rejected"); + } + + [Fact] + public async Task SelectModel_EngineNotReady_ReturnsStableNotReadyError() { + var dispatcher = NewDispatcher(); + + var result = await dispatcher.HandleAsync("session/selectModel", SelectModelArgs("s1", "deepseek", "deepseek-v4-pro")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/not-ready"); + } + + [Fact] + public async Task LlmListProviders_ProjectsModelCatalogGroups() { + // 引擎未就绪时目录来自 settings.json(DshModelCatalogSource 启动期加载)——llm/listProviders 同源投影 + var services = new DshHostServices(new IO.FileSystem.InMemoryFileSystem()); + services.Models.AttachEngine("deepseek", "deepseek-v4-pro", new Dictionary> { + ["deepseek"] = ["deepseek-v4-pro"], + ["agnes"] = ["agnes-video-2.5-flash"], + }); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("llm/listProviders", default(JsonElement)); + + result.Ok.Should().BeTrue(result.Error?.Message); + var providers = result.Value.GetValueOrDefault().EnumerateArray().ToList(); + providers.Count.Should().Be(2); + providers.Select(p => p.GetProperty("id").GetString()).Should().BeEquivalentTo(["deepseek", "agnes"]); + providers.All(p => !string.IsNullOrEmpty(p.GetProperty("name").GetString())).Should().BeTrue(); + } + + [Fact] + public async Task LlmListProviders_WithoutCatalog_ReturnsEmptyArrayStableOk() { + var dispatcher = NewDispatcher(); + + var result = await dispatcher.HandleAsync("llm/listProviders", default(JsonElement)); + + result.Ok.Should().BeTrue(result.Error?.Message); + result.Value.GetValueOrDefault().GetArrayLength().Should().Be(0); + } +} diff --git a/test/unit/join_code_gui.tests/web_host/DshSessionForkTests.cs b/test/unit/join_code_gui.tests/web_host/DshSessionForkTests.cs new file mode 100644 index 000000000..9d84fa3ea --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshSessionForkTests.cs @@ -0,0 +1,77 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// DSH 会话分叉测试(TASK058 B7):session/fork 复制源会话(meta + 转录 + journal 事件)到新会话。 +/// atSeq 截断属深化项(v1 全量复制,文档标注)。 +/// +public sealed class DshSessionForkTests { + /// 构造带两条消息的源会话(真实 State.TranscriptService + 内存文件系统)+ 挂接好的服务容器 + private static async Task<(DshHostServices Services, State.TranscriptService Transcripts, string SourceId)> CreateSourceAsync() { + var fs = new InMemoryFileSystem(); + var services = new DshHostServices(fs); + var transcripts = new State.TranscriptService(fs, "/test/sessions"); + services.Sessions.AttachTranscripts(transcripts); + var created = await services.Sessions.CreateAsync(null, @"D:\proj\demo", "deepseek", "deepseek-v4-pro", CancellationToken.None); + var sourceId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + await transcripts.AppendEntriesAsync(sourceId, [ + new TranscriptEntry { Role = "user", Content = "第一问" }, + new TranscriptEntry { Role = "assistant", Content = "第一答" }, + ], CancellationToken.None); + return (services, transcripts, sourceId); + } + + /// 构造 fork 请求(具名参数 request 信封) + private static JsonElement ForkArgs(string sessionId, long? atSeq = null) => + JsonSerializer.SerializeToElement( + new DshRequestEnvelope { + Request = new() { SessionId = sessionId, AtSeq = atSeq }, + }, DshJsonContext.Default.DshRequestEnvelopeDshSessionForkArgs); + + [Fact] + public async Task Fork_UnknownSession_ReturnsGuidanceError() { + var services = new DshHostServices(new InMemoryFileSystem()); + services.Sessions.AttachTranscripts(new State.TranscriptService(new InMemoryFileSystem(), "/test/sessions")); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("session/fork", ForkArgs("no-such-session")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("session/unknown"); + } + + [Fact] + public async Task Fork_CopiesHistoryToNewSessionId() { + var (services, transcripts, sourceId) = await CreateSourceAsync(); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("session/fork", ForkArgs(sourceId)); + + result.Ok.Should().BeTrue(result.Error?.Message); + var newId = result.Value.GetValueOrDefault().GetProperty("sessionId").GetString() + ?? throw new Xunit.Sdk.XunitException("fork 未返回新会话 ID"); + newId.Should().NotBe(sourceId); + // 新会话历史完整(两条消息) + var entries = await transcripts.LoadTranscriptAsync(newId, CancellationToken.None); + entries.Count.Should().Be(2); + entries[0].Content.Should().Be("第一问"); + // 源会话不受影响 + (await transcripts.LoadTranscriptAsync(sourceId, CancellationToken.None)).Count.Should().Be(2); + } + + [Fact] + public async Task Fork_CopiesJournalEvents() { + var (services, _, sourceId) = await CreateSourceAsync(); + services.Streams.Observe(sourceId, -1, 0); + await services.Streams.PublishAsync(sourceId, "turn/start", new DshTurnStartData { Turn = 1 }); + var dispatcher = new DshRpcDispatcher(services); + + var result = await dispatcher.HandleAsync("session/fork", ForkArgs(sourceId)); + + result.Ok.Should().BeTrue(result.Error?.Message); + var newId = result.Value.GetValueOrDefault().GetProperty("sessionId").GetString() + ?? throw new Xunit.Sdk.XunitException("fork 未返回新会话 ID"); + var events = await services.Journal.LoadAsync(newId, CancellationToken.None); + events.Should().NotBeEmpty("journal 事件随分叉复制(打开新会话历史可还原)"); + events.All(e => e.Seq >= 0).Should().BeTrue("seq 保持源值复制"); + } +} diff --git a/test/unit/join_code_gui.tests/web_host/DshSettingsStorePersistenceTests.cs b/test/unit/join_code_gui.tests/web_host/DshSettingsStorePersistenceTests.cs new file mode 100644 index 000000000..1e2397138 --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshSettingsStorePersistenceTests.cs @@ -0,0 +1,97 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// DSH 设置持久化测试(TASK058 B3):宿主设置从进程内内存退役为 +/// ~/.jcc/dsh-settings.json 真实落盘(RelaxedJsonSerializer + JsonContext DTO,ADR 0042/0133); +/// permission 命名空间接入引擎 枚举词汇(唯一数据源), +/// defaultPreset 写入触发引擎路由回调。 +/// +public sealed class DshSettingsStorePersistenceTests { + /// 构造绑定文件系统的设置存储 + private static DshSettingsStore NewStore(IFileSystem fs, string path = "mem/dsh-settings.json") => + new(fs, path); + + /// 构造 mutate 参数(DTO + JsonContext) + private static JsonElement MutateArgs(string ns, string path, string valueJson, int? expectedRevision = null) => + JsonSerializer.SerializeToElement( + new DshSettingsMutateArgs { + Ns = ns, + Ops = [new DshSettingsOp { Op = "set", Path = [.. path.Split('/')], Value = JsonDocument.Parse(valueJson).RootElement.Clone() }], + ExpectedRevision = expectedRevision, + }, DshJsonContext.Default.DshSettingsMutateArgs); + + /// 从成功结果中取出 value 树 + private static JsonElement ValueOf(DshRpcResult result) { + result.Ok.Should().BeTrue(result.Error?.Message); + return result.Value.GetValueOrDefault().GetProperty("value").Clone(); + } + + [Fact] + public async Task Mutate_PersistsAcrossRestart_SameFileSystem() { + await using var fs = new IO.FileSystem.InMemoryFileSystem(); + var first = NewStore(fs); + first.Mutate(MutateArgs("ui-settings-general", "welcomeNoticeVersion", "\"2026-09-28.1\"")); + + // 重启:新实例从同一文件恢复(进程内内存假数据退役) + var second = NewStore(fs); + await second.InitializeAsync(); + var view = second.Describe().Namespaces.Single(ns => ns.Ns == "ui-settings-general"); + view.Value.GetProperty("welcomeNoticeVersion").GetString().Should().Be("2026-09-28.1"); + } + + [Fact] + public void Describe_AlwaysContainsPermissionNamespace_WithEngineDefaultPreset() { + var store = NewStore(new IO.FileSystem.InMemoryFileSystem()); + + var view = store.Describe().Namespaces.SingleOrDefault(ns => ns.Ns == "permission") + ?? throw new Xunit.Sdk.XunitException("permission 命名空间必须出现在 describe(前端权限预设行依赖)"); + + view.Value.GetProperty("defaultPreset").GetString().Should().Be("auto", "引擎 PermissionMode 枚举默认值(Auto → auto)"); + view.Schema.ValueKind.Should().Be(JsonValueKind.Object, "前端 configForms 需要 schema 信封(ADR 0143 M1.5 教训)"); + } + + [Fact] + public async Task Mutate_PermissionDefaultPreset_PersistsAndRaisesEngineRoute() { + await using var fs = new IO.FileSystem.InMemoryFileSystem(); + var store = NewStore(fs); + var routed = new List(); + store.PermissionPresetChanged += preset => routed.Add(preset); + + var result = store.Mutate(MutateArgs("permission", "defaultPreset", "\"ask\"")); + + ValueOf(result).GetProperty("defaultPreset").GetString().Should().Be("ask"); + routed.Should().BeEquivalentTo(["ask"], "defaultPreset 写入必须触发引擎 SetPermissionModeAsync 路由回调"); + // 持久化读回 + var reopened = NewStore(fs); + await reopened.InitializeAsync(); + reopened.Describe().Namespaces.Single(ns => ns.Ns == "permission") + .Value.GetProperty("defaultPreset").GetString().Should().Be("ask"); + } + + [Fact] + public void Mutate_PermissionUnknownPreset_RejectedFailClosed() { + var store = NewStore(new IO.FileSystem.InMemoryFileSystem()); + + // 值不在引擎 PermissionMode 枚举词汇内 → 拒绝写(防脏值落盘,fail-closed) + var result = store.Mutate(MutateArgs("permission", "defaultPreset", "\"danger-full-access\"")); + + result.Ok.Should().BeFalse(); + result.Error?.Code.Should().Be("settings/rejected"); + result.Error?.Message.Should().Contain("plan/auto/ask/bypass/unattended"); + } + + [Fact] + public async Task Mutate_ConcurrentWrite_PersistsBothNamespaces() { + await using var fs = new IO.FileSystem.InMemoryFileSystem(); + var store = NewStore(fs); + + store.Mutate(MutateArgs("ui-settings-general", "welcomeNoticeVersion", "\"v1\"")); + store.Mutate(MutateArgs("permission", "defaultPreset", "\"plan\"")); + + var reopened = NewStore(fs); + await reopened.InitializeAsync(); + var describe = reopened.Describe(); + describe.Namespaces.Single(ns => ns.Ns == "ui-settings-general").Value.GetProperty("welcomeNoticeVersion").GetString().Should().Be("v1"); + describe.Namespaces.Single(ns => ns.Ns == "permission").Value.GetProperty("defaultPreset").GetString().Should().Be("plan"); + } +} diff --git a/test/unit/join_code_gui.tests/web_host/DshSettingsStoreTests.cs b/test/unit/join_code_gui.tests/web_host/DshSettingsStoreTests.cs index cd879a579..bc18d8f90 100644 --- a/test/unit/join_code_gui.tests/web_host/DshSettingsStoreTests.cs +++ b/test/unit/join_code_gui.tests/web_host/DshSettingsStoreTests.cs @@ -8,8 +8,8 @@ namespace JoinCode.Gui.Tests.WebHost; /// ④ 未知命名空间按需创建。 /// public sealed class DshSettingsStoreTests { - /// 被测设置存储(每个测试类实例独立,避免静态状态串味) - private readonly DshSettingsStore _store = new(); + /// 被测设置存储(每个测试类实例独立文件系统,物理隔离免 Reset) + private readonly DshSettingsStore _store = new(new InMemoryFileSystem()); /// 构造一元调用参数(DTO + JsonContext,与 DSH 前端 settings/mutate 请求体同形状) private static JsonElement MutateArgs(string ns, IReadOnlyList ops, int? expectedRevision = null) => @@ -35,7 +35,6 @@ private static JsonElement ValueOf(DshRpcResult result) { [Fact] public void Describe_AlwaysContainsWelcomeNamespace() { - _store.Reset(); var describe = _store.Describe(); describe.Writable.Should().BeTrue(); var welcome = describe.Namespaces.Single(view => view.Ns == "ui-settings-general"); @@ -46,7 +45,6 @@ public void Describe_AlwaysContainsWelcomeNamespace() { [Fact] public void Mutate_SetWelcomeVersion_PersistsAndDescribeReadsBack() { - _store.Reset(); var result = _store.Mutate(MutateArgs( "ui-settings-general", [Set("welcomeNoticeVersion", "\"2026-09-28.1\"")])); @@ -59,7 +57,6 @@ public void Mutate_SetWelcomeVersion_PersistsAndDescribeReadsBack() { [Fact] public void Mutate_NestedPath_BuildsNestedObject() { - _store.Reset(); var result = _store.Mutate( MutateArgs("demo-nested", [Set("llm/retry/maxRetries", "5")])); var inner = ValueOf(result).GetProperty("llm").GetProperty("retry"); @@ -68,7 +65,6 @@ public void Mutate_NestedPath_BuildsNestedObject() { [Fact] public void Mutate_Unset_RemovesFieldAndSubtree() { - _store.Reset(); _store.Mutate(MutateArgs("demo-unset", [Set("a/b", "1"), Set("a/c", "2")])); var result = _store.Mutate(MutateArgs("demo-unset", [Unset("a/b")])); var node = ValueOf(result).GetProperty("a"); @@ -78,7 +74,6 @@ public void Mutate_Unset_RemovesFieldAndSubtree() { [Fact] public void Mutate_StaleExpectedRevision_FailsWithConflict() { - _store.Reset(); _store.Mutate(MutateArgs("demo-conflict", [Set("x", "1")])); var result = _store.Mutate(MutateArgs("demo-conflict", [Set("x", "2")], expectedRevision: 0)); result.Ok.Should().BeFalse(); @@ -88,7 +83,6 @@ public void Mutate_StaleExpectedRevision_FailsWithConflict() { [Fact] public void Mutate_UnknownNamespace_MutateCreatesIt() { - _store.Reset(); _store.Describe().Namespaces.Should().NotContain(view => view.Ns == "demo-new"); _store.Mutate(MutateArgs("demo-new", [Set("flag", "true")])); _store.Describe().Namespaces.Single(view => view.Ns == "demo-new") @@ -97,7 +91,6 @@ public void Mutate_UnknownNamespace_MutateCreatesIt() { [Fact] public void Describe_WelcomeNamespace_CarriesSchemasteryEnvelope() { - _store.Reset(); var welcome = _store.Describe().Namespaces.Single(view => view.Ns == "ui-settings-general"); welcome.AutoGenerate.Should().BeFalse(); welcome.Schema.ValueKind.Should().Be(JsonValueKind.Object); diff --git a/test/unit/join_code_gui.tests/web_host/DshSubAgentEventProjectionTests.cs b/test/unit/join_code_gui.tests/web_host/DshSubAgentEventProjectionTests.cs new file mode 100644 index 000000000..e1816e961 --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshSubAgentEventProjectionTests.cs @@ -0,0 +1,109 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// 子代理流事件投影测试(TASK058 B6):引擎 AgentStarted/AgentFinished → DSH tool-workflow 事件族。 +/// 契约(前端 workflow-run 节点):run-start{name} 建卡片 → agent-start{seq,label,childId} 加成员 → +/// agent-end{seq,outcome} 定格 → run-end{stopReason} 收尾;seq 为轮内成员序号(start/end 按 AgentId 配对)。 +/// +public sealed class DshSubAgentEventProjectionTests { + /// 轮内首个 agent-start 自动先发 run-start + [Fact] + public void FirstAgentStart_EmitsRunStartThenAgentStart() { + var tracker = new DshAgentRunTracker(runId: "s1:2:agents"); + + var events = tracker.OnAgentStarted(new ChatStreamEvent { + Type = ChatStreamEventType.AgentStarted, + AgentId = "agent-1", + AgentName = "explore", + }); + + events.Should().HaveCount(2); + events[0].Type.Should().Be("tool-workflow/run-start"); + events[0].Data.GetProperty("runId").GetString().Should().Be("s1:2:agents"); + events[0].Data.GetProperty("name").GetString().Should().NotBeNullOrEmpty(); + events[1].Type.Should().Be("tool-workflow/agent-start"); + events[1].Data.GetProperty("runId").GetString().Should().Be("s1:2:agents"); + events[1].Data.GetProperty("seq").GetInt32().Should().Be(1); + events[1].Data.GetProperty("label").GetString().Should().Be("explore"); + events[1].Data.GetProperty("childId").GetString().Should().Be("agent-1"); + } + + [Fact] + public void SubsequentAgentStart_SingleEvent_SequentialSeq() { + var tracker = new DshAgentRunTracker("run"); + tracker.OnAgentStarted(AgentStarted("agent-1", "explore")); + + var events = tracker.OnAgentStarted(AgentStarted("agent-2", "coder")); + + events.Should().ContainSingle().Which.Type.Should().Be("tool-workflow/agent-start"); + events[0].Data.GetProperty("seq").GetInt32().Should().Be(2); + } + + [Fact] + public void AgentEnd_MatchesSeqByAgentId_OutcomeMapsSuccess() { + var tracker = new DshAgentRunTracker("run"); + tracker.OnAgentStarted(AgentStarted("agent-1", "explore")); + tracker.OnAgentStarted(AgentStarted("agent-2", "coder")); + + var events = tracker.OnAgentFinished(new ChatStreamEvent { + Type = ChatStreamEventType.AgentFinished, + AgentId = "agent-1", + AgentSuccess = true, + }); + + events.Should().ContainSingle().Which.Type.Should().Be("tool-workflow/agent-end"); + events[0].Data.GetProperty("seq").GetInt32().Should().Be(1, "按 agent-1 的 start 序号配对"); + events[0].Data.GetProperty("outcome").GetString().Should().Be("completed"); + } + + [Fact] + public void AgentEnd_Failure_OutcomeFailed() { + var tracker = new DshAgentRunTracker("run"); + tracker.OnAgentStarted(AgentStarted("agent-1", "explore")); + + var events = tracker.OnAgentFinished(new ChatStreamEvent { + Type = ChatStreamEventType.AgentFinished, + AgentId = "agent-1", + AgentSuccess = false, + }); + + events[0].Data.GetProperty("outcome").GetString().Should().Be("failed"); + } + + [Fact] + public void AgentEnd_UnknownAgent_IgnoredNoEvent() { + var tracker = new DshAgentRunTracker("run"); + + tracker.OnAgentStarted(AgentStarted("agent-1", "explore")); + var events = tracker.OnAgentFinished(new ChatStreamEvent { + Type = ChatStreamEventType.AgentFinished, + AgentId = "never-started", + }); + + events.Should().BeEmpty("未 start 的 agent end 无法配对,静默丢弃"); + } + + [Fact] + public void RunEnd_WithoutAgents_NoEvent() { + new DshAgentRunTracker("run").BuildRunEnd("completed").Should().BeNull(); + } + + [Fact] + public void RunEnd_AfterAgents_CarriesRunIdAndStopReason() { + var tracker = new DshAgentRunTracker("s1:9:agents"); + tracker.OnAgentStarted(AgentStarted("agent-1", "explore")); + + var end = tracker.BuildRunEnd("cancelled").Should().BeAssignableTo().Subject; + + end.Type.Should().Be("tool-workflow/run-end"); + end.Data.GetProperty("runId").GetString().Should().Be("s1:9:agents"); + end.Data.GetProperty("stopReason").GetString().Should().Be("cancelled"); + } + + /// 构造 AgentStarted 事件 + private static ChatStreamEvent AgentStarted(string agentId, string name) => new() { + Type = ChatStreamEventType.AgentStarted, + AgentId = agentId, + AgentName = name, + }; +} diff --git a/test/unit/join_code_gui.tests/web_host/DshTurnEventProjectionTests.cs b/test/unit/join_code_gui.tests/web_host/DshTurnEventProjectionTests.cs new file mode 100644 index 000000000..fae5e82c9 --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshTurnEventProjectionTests.cs @@ -0,0 +1,54 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// 轮次事件 → DSH 助手消息投影测试(TASK058 B4): +/// 引擎 Thinking 流 → assistant/message 前置 reasoning 块(前端 toAssistantBlock type=reasoning); +/// Complete.Usage(TokenUsage)→ data.usage 四桶(前端 TurnUsagePanel 消费)。 +/// +public sealed class DshTurnEventProjectionTests { + [Fact] + public void ThinkingAndText_ReasoningBlockPrecedesText() { + var projection = DshAssistantReplyProjection.Project(thinking: "先分析问题", text: "答案是 42", usage: null); + + projection.Content.Should().HaveCount(2); + projection.Content[0].Type.Should().Be("reasoning"); + projection.Content[0].Text.Should().Be("先分析问题"); + projection.Content[1].Type.Should().Be("text"); + projection.Content[1].Text.Should().Be("答案是 42"); + projection.Usage.Should().BeNull(); + } + + [Fact] + public void TextOnly_NoReasoningBlock() { + var projection = DshAssistantReplyProjection.Project(thinking: null, text: "直接回答", usage: null); + + projection.Content.Should().ContainSingle().Which.Type.Should().Be("text"); + } + + [Fact] + public void Usage_MapsEngineBucketsToFrontendShape() { + var usage = new TokenUsage(promptTokens: 100, completionTokens: 40) { + CacheReadInputTokens = 60, + CacheCreationInputTokens = 10, + ReasoningTokens = 5, + }; + + var projection = DshAssistantReplyProjection.Project(null, "回复", usage); + + var mapped = projection.Usage.Should().BeOfType().Subject; + mapped.OutputTokens.Should().Be(40); + mapped.CacheReadTokens.Should().Be(60); + mapped.CacheWriteTokens.Should().Be(10); + mapped.UncachedInputTokens.Should().Be(40, "prompt(100) - cacheRead(60) = 未缓存输入"); + mapped.TotalTokens.Should().Be(150, "四桶之和 = 40+60+10+40(对齐前端 tokenTotal)"); + mapped.ReasoningTokens.Should().Be(5); + } + + [Fact] + public void ThinkingOnly_StillEmitsReasoningBlockWithoutText() { + // 纯思考轮(模型只输出 reasoning 无正文)也要把思考内容带给前端 + var projection = DshAssistantReplyProjection.Project(thinking: "仅思考", text: "", usage: null); + + projection.Content.Should().ContainSingle().Which.Type.Should().Be("reasoning"); + } +}