Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

## 四列验收表
Expand All @@ -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 | ⬜ 真机验收待做 |

## 风险与前提

Expand Down
4 changes: 2 additions & 2 deletions modules/apps/gui/web_host/DshHostServices.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
17 changes: 17 additions & 0 deletions modules/apps/gui/web_host/DshRpcProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,21 @@ internal sealed class DshSessionProjectionValues {
public DshSessionModelSelectionProjection? ModelSelection { get; set; }
/// <summary>会话列表元数据</summary>
public DshSessionListMetadata? SessionListMetadata { get; set; }
/// <summary>子代理实例目录(undefined 会让前端子代理面板永久 loading,必须输出数组;TASK058 B8)</summary>
public List<DshSubagentCatalogEntry> SubagentCatalog { get; set; } = [];
}

/// <summary>子代理实例目录条目(实例目录含已完成条目;运行态由前端另查会话状态)</summary>
internal sealed class DshSubagentCatalogEntry {
/// <summary>子代理实例 ID</summary>
public string Id { get; set; } = "";
/// <summary>创建时间(Unix 毫秒)</summary>
public long CreatedAt { get; set; }
/// <summary>实例模式(one-shot / continuable / unknown — 引擎后台代理为一次性)</summary>
public string Mode { get; set; } = "one-shot";
/// <summary>显示名(可选)</summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Label { get; set; }
}

/// <summary>模型选择投影(上次使用 / 下次待用,null 表示无)</summary>
Expand Down Expand Up @@ -1159,6 +1174,8 @@ internal sealed class DshStreamErrorBody {
[JsonSerializable(typeof(DshRequestEnvelope<DshSessionProjectionsArgs>))]
[JsonSerializable(typeof(DshSessionProjectionsArgs))]
[JsonSerializable(typeof(DshSessionProjectionSnapshot))]
[JsonSerializable(typeof(DshSubagentCatalogEntry))]
[JsonSerializable(typeof(List<DshSubagentCatalogEntry>))]
[JsonSerializable(typeof(DshRequestEnvelope<DshSessionFollowArgs>))]
[JsonSerializable(typeof(DshSessionFollowArgs))]
[JsonSerializable(typeof(DshRequestEnvelope<DshSessionPageArgs>))]
Expand Down
22 changes: 16 additions & 6 deletions modules/apps/gui/web_host/DshSessionStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ internal sealed class DshSessionStore {
/// <summary>当前会话存储(启动为轻量落盘实例,引擎就绪后切换为引擎实例)</summary>
private ITranscriptService? _transcripts;

/// <summary>宿主事件日志(subagentCatalog 投影回放源,TASK058 B8;null 时目录恒空)</summary>
private readonly DshSessionJournal? _journal;

/// <summary>构造会话注册表</summary>
/// <param name="journal">宿主事件日志(可选 — subagentCatalog 回放)</param>
internal DshSessionStore(DshSessionJournal? journal = null) => _journal = journal;

/// <summary>注入会话存储 — 支持热切换(唯一数据源:同一份 <c>~/.jcc/sessions</c> 落盘数据)</summary>
/// <param name="transcripts">会话存储(轻量落盘实例或引擎实例)</param>
internal void AttachTranscripts(ITranscriptService transcripts) => _transcripts = transcripts;
Expand Down Expand Up @@ -59,22 +66,23 @@ internal async Task<IReadOnlyList<DshSessionListItem>> ListAsync(int limit, Canc
return (title, entries.Count);
}

/// <summary>把一条引擎会话摘要投影成 DSH 会话条目</summary>
private static async Task<DshSessionListItem> ProjectAsync(
/// <summary>把一条引擎会话摘要投影成 DSH 会话条目(含 journal 回放的子代理目录)</summary>
private async Task<DshSessionListItem> 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),
AgentAvailable = true,
Running = false,
Blank = entries.Count == 0,
Cwd = info?.ProjectPath ?? "",
Projections = ProjectValues(customTitle, info, entries),
Projections = ProjectValues(customTitle, info, entries, journalEvents),
};
}

Expand Down Expand Up @@ -195,7 +203,8 @@ private static List<DshSessionRecord> Records(string sessionId, IReadOnlyList<Tr
var customTitle = await transcripts.GetCustomTitleAsync(sessionId, cancellationToken);
var info = await transcripts.GetSessionInfoAsync(sessionId, cancellationToken);
var entries = await transcripts.LoadTranscriptAsync(sessionId, cancellationToken);
return (ProjectValues(customTitle, info, entries), info, entries);
var journalEvents = _journal is null ? [] : await _journal.LoadAsync(sessionId, cancellationToken);
return (ProjectValues(customTitle, info, entries, journalEvents), info, entries);
}

/// <summary>
Expand Down Expand Up @@ -276,8 +285,8 @@ await transcripts.EnsureSessionAsync(newId, sourceInfo with {
return newId;
}

/// <summary>投影键值(标题 / 代理预设 / 模型选择 / 列表元数据)</summary>
private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList<TranscriptEntry> entries) {
/// <summary>投影键值(标题 / 代理预设 / 模型选择 / 列表元数据 / 子代理实例目录 — TASK058 B8)</summary>
private static DshSessionProjections ProjectValues(string? customTitle, SessionInfo? info, IReadOnlyList<TranscriptEntry> entries, IReadOnlyList<DshSessionEvent>? journalEvents = null) {
var lastPrompt = entries.LastOrDefault(entry => entry.Type is null && !entry.IsSidechain && entry.Role == "user");
return new DshSessionProjections {
AsOfSeq = entries.Count,
Expand All @@ -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 ?? []),
},
};
}
Expand Down
36 changes: 36 additions & 0 deletions modules/apps/gui/web_host/DshSubagentCatalogProjector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
namespace JoinCode.Gui.WebHost;

/// <summary>
/// 子代理实例目录回放器(TASK058 B8,翻译单点):journal 的 tool-workflow/agent-start 事件
/// → <see cref="DshSubagentCatalogEntry"/>(实例目录含已完成条目,重启后可还原;
/// 前端运行态由会话状态另行查询)。createdAt 取事件时间戳。
/// </summary>
internal static class DshSubagentCatalogProjector {
/// <summary>从 journal 事件回放目录(无事件返回空列表 — 前端 ready 态)</summary>
internal static List<DshSubagentCatalogEntry> Project(IReadOnlyList<DshSessionEvent> events) {
var entries = new List<DshSubagentCatalogEntry>();
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;
}

/// <summary>取 data 树的字符串字段(缺失/非字符串返回 null)</summary>
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;
}
73 changes: 73 additions & 0 deletions test/unit/join_code_gui.tests/web_host/DshSubagentCatalogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
namespace JoinCode.Gui.Tests.WebHost;

/// <summary>
/// subagentCatalog 投影测试(TASK058 B8):前端子代理面板读 projections.values.subagentCatalog
/// (undefined → loading 态永久挂起,空数组 → ready 无子代理)。目录条目从 journal 的
/// tool-workflow/agent-start 事件回放(实例目录:含已完成条目,重启后可还原)。
/// </summary>
public sealed class DshSubagentCatalogTests {
/// <summary>建带 journal 的服务容器</summary>
private static DshHostServices NewServices() => new(new InMemoryFileSystem());

/// <summary>建带两条消息的源会话(真实转录)</summary>
private static async Task<string> 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");
}
}