diff --git "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" index 13bc1ab5a..1aaaf692c 100644 --- "a/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" +++ "b/docs/proposed/task/TASK058-DSH\345\274\225\346\223\216\346\267\261\345\272\246\345\257\271\351\275\220\344\270\216DTO\347\273\237\344\270\200.md" @@ -53,7 +53,7 @@ account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAn | 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 | +| B6 | 子代理流事件:AgentStarted/Finished → tool-workflow 事件族 + subagentCatalog 投影回放(B8) | ✅ 单元 10/10 | | B7 | 会话管理:`session/fork` 复制 meta/转录/journal(清空/回退无前端 RPC 消费点,不做) | ✅ 单元 3/3 | ## 四列验收表 @@ -67,6 +67,7 @@ account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAn | B5 `commands/list|execute` 路由 | 斜杠命令补全与执行 | ⬜ | ⬜ | | B6 agents/interrupt/follow 子代理 | 子代理面板/轨迹 | ⬜ | ⬜ | | B7 fork/clear/rewind | 会话菜单操作 | ⬜ | ⬜ | +| B8 DshSubagentCatalogProjector journal 回放 subagentCatalog(空数组消除前端 loading 挂起) | 子代理面板目录 | ✅ 单元 3/3 | ⬜ 真机验收待做 | ## 风险与前提 diff --git a/modules/apps/gui/web_host/DshHostServices.cs b/modules/apps/gui/web_host/DshHostServices.cs index a58685181..68546c9bb 100644 --- a/modules/apps/gui/web_host/DshHostServices.cs +++ b/modules/apps/gui/web_host/DshHostServices.cs @@ -13,9 +13,9 @@ internal sealed class DshHostServices { internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = null, string? journalDirectory = null) { Workspaces = new DshWorkspaceStore(fileSystem, workspaceFilePath); Settings = new DshSettingsStore(fileSystem); - Sessions = new DshSessionStore(); - Models = new DshModelCatalogSource(); Journal = new DshSessionJournal(fileSystem, journalDirectory ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_journal")); + Sessions = new DshSessionStore(Journal); + Models = new DshModelCatalogSource(); Streams = new DshSessionStreamHub(Journal); Approvals = new DshApprovalBroker(); } diff --git a/modules/apps/gui/web_host/DshRpcProtocol.cs b/modules/apps/gui/web_host/DshRpcProtocol.cs index e48d56b52..4b8a04c22 100644 --- a/modules/apps/gui/web_host/DshRpcProtocol.cs +++ b/modules/apps/gui/web_host/DshRpcProtocol.cs @@ -359,6 +359,21 @@ internal sealed class DshSessionProjectionValues { public DshSessionModelSelectionProjection? ModelSelection { get; set; } /// 会话列表元数据 public DshSessionListMetadata? SessionListMetadata { get; set; } + /// 子代理实例目录(undefined 会让前端子代理面板永久 loading,必须输出数组;TASK058 B8) + public List SubagentCatalog { get; set; } = []; +} + +/// 子代理实例目录条目(实例目录含已完成条目;运行态由前端另查会话状态) +internal sealed class DshSubagentCatalogEntry { + /// 子代理实例 ID + public string Id { get; set; } = ""; + /// 创建时间(Unix 毫秒) + public long CreatedAt { get; set; } + /// 实例模式(one-shot / continuable / unknown — 引擎后台代理为一次性) + public string Mode { get; set; } = "one-shot"; + /// 显示名(可选) + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Label { get; set; } } /// 模型选择投影(上次使用 / 下次待用,null 表示无) @@ -1159,6 +1174,8 @@ internal sealed class DshStreamErrorBody { [JsonSerializable(typeof(DshRequestEnvelope))] [JsonSerializable(typeof(DshSessionProjectionsArgs))] [JsonSerializable(typeof(DshSessionProjectionSnapshot))] +[JsonSerializable(typeof(DshSubagentCatalogEntry))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(DshRequestEnvelope))] [JsonSerializable(typeof(DshSessionFollowArgs))] [JsonSerializable(typeof(DshRequestEnvelope))] diff --git a/modules/apps/gui/web_host/DshSessionStore.cs b/modules/apps/gui/web_host/DshSessionStore.cs index 23049d15e..9938a9f5c 100644 --- a/modules/apps/gui/web_host/DshSessionStore.cs +++ b/modules/apps/gui/web_host/DshSessionStore.cs @@ -10,6 +10,13 @@ internal sealed class DshSessionStore { /// 当前会话存储(启动为轻量落盘实例,引擎就绪后切换为引擎实例) private ITranscriptService? _transcripts; + /// 宿主事件日志(subagentCatalog 投影回放源,TASK058 B8;null 时目录恒空) + private readonly DshSessionJournal? _journal; + + /// 构造会话注册表 + /// 宿主事件日志(可选 — subagentCatalog 回放) + internal DshSessionStore(DshSessionJournal? journal = null) => _journal = journal; + /// 注入会话存储 — 支持热切换(唯一数据源:同一份 ~/.jcc/sessions 落盘数据) /// 会话存储(轻量落盘实例或引擎实例) internal void AttachTranscripts(ITranscriptService transcripts) => _transcripts = transcripts; @@ -59,14 +66,15 @@ internal async Task> ListAsync(int limit, Canc return (title, entries.Count); } - /// 把一条引擎会话摘要投影成 DSH 会话条目 - private static async Task ProjectAsync( + /// 把一条引擎会话摘要投影成 DSH 会话条目(含 journal 回放的子代理目录) + private async Task ProjectAsync( ITranscriptService transcripts, TranscriptSummary summary, CancellationToken cancellationToken) { var customTitle = await transcripts.GetCustomTitleAsync(summary.SessionId, cancellationToken); var info = await transcripts.GetSessionInfoAsync(summary.SessionId, cancellationToken); var entries = await transcripts.LoadTranscriptAsync(summary.SessionId, cancellationToken); + var journalEvents = _journal is null ? [] : await _journal.LoadAsync(summary.SessionId, cancellationToken); return new DshSessionListItem { SessionId = summary.SessionId, UpdatedAt = ToUnixMilliseconds(summary.LastModifiedAt), @@ -74,7 +82,7 @@ private static async Task ProjectAsync( Running = false, Blank = entries.Count == 0, Cwd = info?.ProjectPath ?? "", - Projections = ProjectValues(customTitle, info, entries), + Projections = ProjectValues(customTitle, info, entries, journalEvents), }; } @@ -195,7 +203,8 @@ private static List Records(string sessionId, IReadOnlyList @@ -276,8 +285,8 @@ await transcripts.EnsureSessionAsync(newId, sourceInfo with { return newId; } - /// 投影键值(标题 / 代理预设 / 模型选择 / 列表元数据) - private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList entries) { + /// 投影键值(标题 / 代理预设 / 模型选择 / 列表元数据 / 子代理实例目录 — TASK058 B8) + private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList entries, IReadOnlyList? journalEvents = null) { var lastPrompt = entries.LastOrDefault(entry => entry.Type is null && !entry.IsSidechain && entry.Role == "user"); return new DshSessionProjections { AsOfSeq = entries.Count, @@ -292,6 +301,7 @@ private static DshSessionProjections ProjectValues(string? customTitle, SessionI Blank = entries.Count == 0, LastPromptAt = lastPrompt is null ? null : ToUnixMilliseconds(lastPrompt.Timestamp), }, + SubagentCatalog = DshSubagentCatalogProjector.Project(journalEvents ?? []), }, }; } diff --git a/modules/apps/gui/web_host/DshSubagentCatalogProjector.cs b/modules/apps/gui/web_host/DshSubagentCatalogProjector.cs new file mode 100644 index 000000000..3698c75c7 --- /dev/null +++ b/modules/apps/gui/web_host/DshSubagentCatalogProjector.cs @@ -0,0 +1,36 @@ +namespace JoinCode.Gui.WebHost; + +/// +/// 子代理实例目录回放器(TASK058 B8,翻译单点):journal 的 tool-workflow/agent-start 事件 +/// → (实例目录含已完成条目,重启后可还原; +/// 前端运行态由会话状态另行查询)。createdAt 取事件时间戳。 +/// +internal static class DshSubagentCatalogProjector { + /// 从 journal 事件回放目录(无事件返回空列表 — 前端 ready 态) + internal static List Project(IReadOnlyList events) { + var entries = new List(); + foreach (var sessionEvent in events) { + if (!string.Equals(sessionEvent.Type, "tool-workflow/agent-start", StringComparison.Ordinal)) { + continue; + } + if (ReadString(sessionEvent.Data, "childId") is not { } childId) { + continue; + } + entries.Add(new DshSubagentCatalogEntry { + Id = childId, + CreatedAt = sessionEvent.Time, + Mode = "one-shot", + Label = ReadString(sessionEvent.Data, "label"), + }); + } + return entries; + } + + /// 取 data 树的字符串字段(缺失/非字符串返回 null) + private static string? ReadString(JsonElement data, string property) => + data.ValueKind == JsonValueKind.Object + && data.TryGetProperty(property, out var value) + && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; +} diff --git a/test/unit/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs b/test/unit/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs new file mode 100644 index 000000000..8afc3d363 --- /dev/null +++ b/test/unit/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs @@ -0,0 +1,73 @@ +namespace JoinCode.Gui.Tests.WebHost; + +/// +/// subagentCatalog 投影测试(TASK058 B8):前端子代理面板读 projections.values.subagentCatalog +/// (undefined → loading 态永久挂起,空数组 → ready 无子代理)。目录条目从 journal 的 +/// tool-workflow/agent-start 事件回放(实例目录:含已完成条目,重启后可还原)。 +/// +public sealed class DshSubagentCatalogTests { + /// 建带 journal 的服务容器 + private static DshHostServices NewServices() => new(new InMemoryFileSystem()); + + /// 建带两条消息的源会话(真实转录) + private static async Task CreateSessionAsync(DshHostServices services) { + services.Sessions.AttachTranscripts(new State.TranscriptService(new InMemoryFileSystem(), "/test/sessions")); + var created = await services.Sessions.CreateAsync(null, @"D:\proj\demo", "deepseek", "deepseek-v4-pro", CancellationToken.None); + return (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId; + } + + [Fact] + public async Task Projections_EmptySession_CatalogIsReadyStateEmptyArray() { + var services = NewServices(); + var sessionId = await CreateSessionAsync(services); + + var projection = await services.Sessions.ProjectionsAsync(sessionId, CancellationToken.None) + ?? throw new Xunit.Sdk.XunitException("投影为 null"); + var values = projection.Values; + // 前端契约:undefined → "loading"(面板挂起);[] → "ready"(无子代理)。必须输出空数组。 + values.SubagentCatalog.Should().NotBeNull("undefined 会让前端子代理面板永久 loading").And.BeEmpty(); + } + + [Fact] + public async Task Projections_AgentStartEvents_CatalogContainsEntries() { + var services = NewServices(); + var sessionId = await CreateSessionAsync(services); + services.Streams.Observe(sessionId, -1, 0); + await services.Streams.PublishAsync(sessionId, "tool-workflow/run-start", new DshWorkflowRunStartData { RunId = "r1", Name = "subagents" }); + await services.Streams.PublishAsync(sessionId, "tool-workflow/agent-start", new DshWorkflowAgentStartData { + RunId = "r1", Seq = 1, Label = "explore", ChildId = "child-1", + }); + await services.Streams.PublishAsync(sessionId, "tool-workflow/agent-start", new DshWorkflowAgentStartData { + RunId = "r1", Seq = 2, Label = "coder", ChildId = "child-2", + }); + + var projection = await services.Sessions.ProjectionsAsync(sessionId, CancellationToken.None) + ?? throw new Xunit.Sdk.XunitException("投影为 null"); + var catalog = projection.Values.SubagentCatalog.Should().NotBeNull().And.Subject.ToList(); + catalog.Count.Should().Be(2); + catalog[0].Id.Should().Be("child-1"); + catalog[0].Label.Should().Be("explore"); + catalog[0].Mode.Should().Be("one-shot"); + catalog[0].CreatedAt.Should().BeGreaterThan(0, "createdAt 取事件时间戳(Unix 毫秒)"); + catalog[1].Id.Should().Be("child-2"); + } + + [Fact] + public async Task Projections_AgentEnd_KeepsEntryAsInstanceCatalog() { + var services = NewServices(); + var sessionId = await CreateSessionAsync(services); + services.Streams.Observe(sessionId, -1, 0); + await services.Streams.PublishAsync(sessionId, "tool-workflow/agent-start", new DshWorkflowAgentStartData { + RunId = "r1", Seq = 1, Label = "explore", ChildId = "child-1", + }); + await services.Streams.PublishAsync(sessionId, "tool-workflow/agent-end", new DshWorkflowAgentEndData { + RunId = "r1", Seq = 1, Outcome = "completed", + }); + + var projection = await services.Sessions.ProjectionsAsync(sessionId, CancellationToken.None) + ?? throw new Xunit.Sdk.XunitException("投影为 null"); + + // catalog 是实例目录(含已完成),前端 activity 另查运行态 + projection.Values.SubagentCatalog.Should().ContainSingle().Which.Id.Should().Be("child-1"); + } +}