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");
+ }
+}