From 1fe45c34fc041aeef3aafceb902f673000f38c19 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 20:39:29 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20TASK058=20P2-B1=20workspaceFiles=20?=
=?UTF-8?q?=E6=96=87=E4=BB=B6=E6=A0=91=E5=9B=9B=E6=96=B9=E6=B3=95=20|=20?=
=?UTF-8?q?=E5=86=B3=E7=AD=96:=20DshWorkspaceFilesService=20=E5=BC=B9?=
=?UTF-8?q?=E6=A0=88=E5=BC=8F=E8=B7=AF=E5=BE=84=E8=A7=A3=E6=9E=90=EF=BC=88?=
=?UTF-8?q?=E5=85=8D=20GetFullPath=20=E7=9B=98=E7=AC=A6=E5=8C=96=EF=BC=8CI?=
=?UTF-8?q?nMemory/Physical=20=E5=90=8C=E6=9E=84=EF=BC=9B=E6=9D=9C?=
=?UTF-8?q?=E7=BB=9D=E6=A0=B9=E5=89=8D=E7=BC=80=E7=A2=B0=E6=92=9E=E8=AF=AF?=
=?UTF-8?q?=E5=88=A4=EF=BC=89=EF=BC=8Cversion=3Dsize-mtime=20=E5=90=88?=
=?UTF-8?q?=E6=88=90=EF=BC=8C=E9=98=B2=E7=A9=BF=E8=B6=8A=E5=BC=95=E5=AF=BC?=
=?UTF-8?q?=E9=94=99=E8=AF=AF=EF=BC=9B=E6=A0=B9=3D=E4=BC=9A=E8=AF=9D=20cwd?=
=?UTF-8?q?=EF=BC=88GetSessionCwdAsync=20=E6=96=B0=E5=A2=9E=E5=AE=B9?=
=?UTF-8?q?=E5=BF=8D=E6=97=A0=20transcript=EF=BC=89=EF=BC=9Bchanges=20?=
=?UTF-8?q?=E6=B5=81=E5=B1=9E=E6=B7=B1=E5=8C=96=E9=A1=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../web_host/DshHostServices.cs | 4 +
.../web_host/DshRpcDispatcher.cs | 53 +++++++
.../NativeGui.Core/web_host/DshRpcProtocol.cs | 72 ++++++++++
.../web_host/DshSessionStore.cs | 8 ++
.../web_host/DshWorkspaceFilesService.cs | 134 ++++++++++++++++++
.../web_host/DshWorkspaceFilesTests.cs | 114 +++++++++++++++
6 files changed, 385 insertions(+)
create mode 100644 native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs
create mode 100644 native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs
diff --git a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
index 68546c9bb..1db42d64b 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
@@ -18,6 +18,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul
Models = new DshModelCatalogSource();
Streams = new DshSessionStreamHub(Journal);
Approvals = new DshApprovalBroker();
+ WorkspaceFiles = new DshWorkspaceFilesService(Sessions, fileSystem);
}
/// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误)
@@ -47,4 +48,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul
/// 权限确认桥(引擎确认点 → $events waterfall → 前端回帖,TASK058 B1)
internal DshApprovalBroker Approvals { get; }
+
+ /// 工作区文件树(workspaceFiles/list|read|readBytes|stat,TASK058 P2-B1)
+ internal DshWorkspaceFilesService WorkspaceFiles { get; }
}
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
index 103112566..12a78ea11 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
@@ -64,6 +64,11 @@ internal DshRpcDispatcher(DshHostServices services) {
["workspace/unpinSession"] = WorkspaceUnpinSession,
// 宿主暂不提供默认工作区(M2 起由自有引擎的会话/工作区目录决定)
["workspace/initializeDefault"] = Sync(_ => OkNull()),
+ // 工作区文件树(TASK058 P2-B1;changes 流属深化项,前端会收到未注册引导错误)
+ ["workspaceFiles/list"] = WorkspaceFilesList,
+ ["workspaceFiles/read"] = WorkspaceFilesRead,
+ ["workspaceFiles/readBytes"] = WorkspaceFilesReadBytes,
+ ["workspaceFiles/stat"] = WorkspaceFilesStat,
}.ToFrozenDictionary(StringComparer.Ordinal);
}
@@ -345,6 +350,54 @@ private DshRpcResult LlmListProviders(JsonElement args) => Ok(_services.Models.C
.Select(group => new DshProviderInfo { Id = group.Id, Name = group.Name })
.ToList());
+ /// workspaceFiles 四方法共用参数解析(失败返回稳定引导错误)
+ private DshRpcResult? ParseWorkspaceFilesArgs(JsonElement args, out string sessionId, out string path) {
+ var request = args.Deserialize(DshJsonContext.Default.DshWorkspaceFilesArgs);
+ sessionId = request?.WorkspaceFileScopeId ?? "";
+ path = request?.Path ?? "";
+ if (string.IsNullOrWhiteSpace(sessionId)) {
+ return Fail("workspaceFiles/rejected",
+ $"workspaceFiles 方法需要 workspaceFileScopeId(会话 ID)与 path;收到 args={Describe(args)}");
+ }
+ return null;
+ }
+
+ /// workspaceFiles/list:目录条目(目录在前按名排序)
+ private async Task WorkspaceFilesList(JsonElement args, CancellationToken cancellationToken) {
+ var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path);
+ if (error is not null) {
+ return error;
+ }
+ return await _services.WorkspaceFiles.ListAsync(sessionId, path, cancellationToken);
+ }
+
+ /// workspaceFiles/read:读文本(全量 + 行数 + eof)
+ private async Task WorkspaceFilesRead(JsonElement args, CancellationToken cancellationToken) {
+ var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path);
+ if (error is not null) {
+ return error;
+ }
+ return await _services.WorkspaceFiles.ReadAsync(sessionId, path, cancellationToken);
+ }
+
+ /// workspaceFiles/readBytes:读字节(base64)
+ private async Task WorkspaceFilesReadBytes(JsonElement args, CancellationToken cancellationToken) {
+ var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path);
+ if (error is not null) {
+ return error;
+ }
+ return await _services.WorkspaceFiles.ReadBytesAsync(sessionId, path, cancellationToken);
+ }
+
+ /// workspaceFiles/stat:文件元数据(绝对路径 + version)
+ private async Task WorkspaceFilesStat(JsonElement args, CancellationToken cancellationToken) {
+ var error = ParseWorkspaceFilesArgs(args, out var sessionId, out var path);
+ if (error is not null) {
+ return error;
+ }
+ return await _services.WorkspaceFiles.StatAsync(sessionId, path, cancellationToken);
+ }
+
/// commands/list:引擎斜杠命令目录投影(TASK058 B5 — 引擎 [ChatCommand] 特性为唯一数据源)
private DshRpcResult CommandsList(JsonElement args) => Ok(
(_services.Engine?.GetAvailableSlashCommands() ?? [])
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
index 4b8a04c22..47353aed8 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
@@ -141,6 +141,73 @@ internal sealed class DshEventErrorBody {
/// $events/result 回帖响应(前端只看 ok,无业务载荷)
internal sealed class DshEventsResultValue;
+/// workspaceFiles 四方法共用参数(前端 lookup 字段 workspaceFileScopeId 即 sessionId)
+internal sealed class DshWorkspaceFilesArgs {
+ /// 会话 ID(文件树根 = 会话 cwd)
+ public string WorkspaceFileScopeId { get; set; } = "";
+ /// 相对路径(空 = 根目录)
+ public string Path { get; set; } = "";
+}
+
+/// workspaceFiles/list 响应
+internal sealed class DshWorkspaceFilesListResult {
+ /// 请求的相对路径(回显)
+ public string Path { get; set; } = "";
+ /// 目录条目(目录在前按名排序)
+ public List Entries { get; set; } = [];
+ /// 是否超过截断阈值
+ public bool Truncated { get; set; }
+}
+
+/// 文件树条目
+internal sealed class DshWorkspaceFileEntry {
+ /// 名称(不含路径)
+ public string Name { get; set; } = "";
+ /// 类型(file/directory/other)
+ public string Type { get; set; } = "file";
+ /// 字节数(文件可选)
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public long? Size { get; set; }
+}
+
+/// workspaceFiles/read 响应
+internal sealed class DshWorkspaceFilesReadResult {
+ /// 文本起始偏移(v1 恒 0)
+ public int Offset { get; set; }
+ /// 文本内容
+ public string Text { get; set; } = "";
+ /// 行数
+ public int Lines { get; set; }
+ /// 是否读到末尾
+ public bool Eof { get; set; }
+ /// 绝对路径
+ public string AbsolutePath { get; set; } = "";
+ /// 版本(size-mtime 合成,前端透传比较失效)
+ public string Version { get; set; } = "";
+}
+
+/// workspaceFiles/readBytes 响应(data 为 base64)
+internal sealed class DshWorkspaceFilesReadBytesResult {
+ /// 字节起始偏移(v1 恒 0)
+ public int Offset { get; set; }
+ /// base64 编码字节
+ public string Data { get; set; } = "";
+ /// 是否读到末尾
+ public bool Eof { get; set; }
+ /// 绝对路径
+ public string AbsolutePath { get; set; } = "";
+ /// 版本
+ public string Version { get; set; } = "";
+}
+
+/// workspaceFiles/stat 响应
+internal sealed class DshWorkspaceFilesStatResult {
+ /// 绝对路径
+ public string AbsolutePath { get; set; } = "";
+ /// 版本
+ public string Version { get; set; } = "";
+}
+
/// commands/list 条目(前端以 /{name} 组装命令行)
internal sealed class DshCommandInfo {
/// 命令名(无 / 前缀)
@@ -1241,5 +1308,10 @@ internal sealed class DshStreamErrorBody {
[JsonSerializable(typeof(JsonElement[]))]
[JsonSerializable(typeof(Dictionary))]
[JsonSerializable(typeof(DshRpcErrorDetails))]
+[JsonSerializable(typeof(DshWorkspaceFilesArgs))]
+[JsonSerializable(typeof(DshWorkspaceFilesListResult))]
+[JsonSerializable(typeof(DshWorkspaceFilesReadResult))]
+[JsonSerializable(typeof(DshWorkspaceFilesReadBytesResult))]
+[JsonSerializable(typeof(DshWorkspaceFilesStatResult))]
[JsonSerializable(typeof(object))]
internal sealed partial class DshJsonContext : JsonSerializerContext;
diff --git a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs
index 7f92898d0..c48180a33 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshSessionStore.cs
@@ -207,6 +207,14 @@ private static List Records(string sessionId, IReadOnlyList读会话工作目录(workspaceFiles 文件树根);meta 缺失/无 ProjectPath 返回 null
+ internal async Task GetSessionCwdAsync(string sessionId, CancellationToken cancellationToken) {
+ if (_transcripts is not { } transcripts) {
+ return null;
+ }
+ return (await transcripts.GetSessionInfoAsync(sessionId, cancellationToken))?.ProjectPath;
+ }
+
///
/// 全部会话的投影映射(session/control 基线);引擎未就绪或没有会话时为空。
///
diff --git a/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs
new file mode 100644
index 000000000..c5395b0d5
--- /dev/null
+++ b/native_gui/src/NativeGui.Core/web_host/DshWorkspaceFilesService.cs
@@ -0,0 +1,134 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// 工作区文件树服务(TASK058 P2-B1):DSH 前端 sidebar-files 的 list/read/readBytes/stat 四一元方法后端。
+/// 根目录 = 会话 cwd(SessionInfo.ProjectPath);路径解析后必须落在根内(防 ../ 穿越);
+/// version 用「size-mtimeTicks」合成(前端仅透传比较失效,无需内容哈希);changes 流属深化项。
+///
+internal sealed class DshWorkspaceFilesService(DshSessionStore sessions, IFileSystem fs) {
+ /// 列出目录条目(目录在前、名称不区分大小写排序),截断阈值 500
+ internal async Task ListAsync(string sessionId, string path, CancellationToken cancellationToken) {
+ var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken);
+ if (error is not null || absolutePath is null) {
+ return error ?? AsInternalError();
+ }
+ if (!fs.DirectoryExists(absolutePath)) {
+ return DshRpcResults.Fail("workspaceFiles/not-found", $"目录不存在:{path};在文件树里刷新后重试");
+ }
+ var directories = fs.GetDirectories(absolutePath, "*", SearchOption.TopDirectoryOnly)
+ .Select(name => Path.GetFileName(name))
+ .OrderBy(name => name, StringComparer.OrdinalIgnoreCase);
+ var files = fs.GetFiles(absolutePath, "*", SearchOption.TopDirectoryOnly)
+ .Select(fullPath => new { Name = Path.GetFileName(fullPath), Size = TryGetLength(fullPath) })
+ .OrderBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase);
+ var entries = new List();
+ foreach (var name in directories) {
+ entries.Add(new DshWorkspaceFileEntry { Name = name, Type = "directory" });
+ }
+ foreach (var file in files) {
+ entries.Add(new DshWorkspaceFileEntry { Name = file.Name, Type = "file", Size = file.Size });
+ }
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshWorkspaceFilesListResult { Path = path, Entries = entries, Truncated = entries.Count > 500 },
+ DshJsonContext.Default.DshWorkspaceFilesListResult));
+ }
+
+ /// 读文本(v1 全量读 + 行数统计 + eof;range 分页属深化项,offset 恒 0)
+ internal async Task ReadAsync(string sessionId, string path, CancellationToken cancellationToken) {
+ var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken);
+ if (error is not null || absolutePath is null) {
+ return error ?? AsInternalError();
+ }
+ if (!fs.FileExists(absolutePath)) {
+ return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试");
+ }
+ var text = await fs.ReadAllTextAsync(absolutePath, cancellationToken);
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshWorkspaceFilesReadResult {
+ Offset = 0, Text = text, Lines = 1 + text.Count('\n'), Eof = true,
+ AbsolutePath = absolutePath, Version = VersionFor(absolutePath),
+ },
+ DshJsonContext.Default.DshWorkspaceFilesReadResult));
+ }
+
+ /// 读字节(base64 编码返回;v1 全量读,大文件分页属深化项)
+ internal async Task ReadBytesAsync(string sessionId, string path, CancellationToken cancellationToken) {
+ var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken);
+ if (error is not null || absolutePath is null) {
+ return error ?? AsInternalError();
+ }
+ if (!fs.FileExists(absolutePath)) {
+ return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试");
+ }
+ var bytes = await fs.ReadAllBytesAsync(absolutePath, cancellationToken);
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshWorkspaceFilesReadBytesResult {
+ Offset = 0, Data = Convert.ToBase64String(bytes), Eof = true,
+ AbsolutePath = absolutePath, Version = VersionFor(absolutePath),
+ },
+ DshJsonContext.Default.DshWorkspaceFilesReadBytesResult));
+ }
+
+ /// 文件元数据(绝对路径 + version)
+ internal async Task StatAsync(string sessionId, string path, CancellationToken cancellationToken) {
+ var (error, absolutePath) = await ResolveRootedAsync(sessionId, path, cancellationToken);
+ if (error is not null || absolutePath is null) {
+ return error ?? AsInternalError();
+ }
+ if (!fs.FileExists(absolutePath)) {
+ return DshRpcResults.Fail("workspaceFiles/not-found", $"文件不存在:{path};在文件树里刷新后重试");
+ }
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshWorkspaceFilesStatResult { AbsolutePath = absolutePath, Version = VersionFor(absolutePath) },
+ DshJsonContext.Default.DshWorkspaceFilesStatResult));
+ }
+
+ /// 解析「会话 cwd + 相对路径」为根内绝对路径;穿越/无 cwd 返回引导错误(非异常)
+ private async Task<(DshRpcResult? Error, string? AbsolutePath)> ResolveRootedAsync(string sessionId, string path, CancellationToken cancellationToken) {
+ var cwd = await sessions.GetSessionCwdAsync(sessionId, cancellationToken);
+ if (string.IsNullOrWhiteSpace(cwd)) {
+ return (DshRpcResults.Fail("workspaceFiles/rejected",
+ $"会话 {sessionId} 无工作目录(meta.json 缺 ProjectPath);用 workspace/create 建会话或重开会话后重试"), null);
+ }
+ // 手工段解析(弹栈式防穿越):不用 Path.GetFullPath —— 它会给「/x/y」补盘符,
+ // 与 InMemoryFileSystem 的字面量路径键不匹配;弹栈法同时杜绝「根前缀碰撞」误判
+ var root = cwd.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
+ var segments = new List();
+ foreach (var part in path.Split('/', '\\')) {
+ if (part is "" or ".") {
+ continue;
+ }
+ if (part == "..") {
+ if (segments.Count == 0) {
+ return (DshRpcResults.Fail("workspaceFiles/rejected",
+ $"路径 {path} 解析到工作区外(根={root});请使用工作区内的相对路径"), null);
+ }
+ segments.RemoveAt(segments.Count - 1);
+ } else {
+ segments.Add(part);
+ }
+ }
+ var candidate = segments.Count == 0
+ ? root
+ : root + Path.DirectorySeparatorChar + string.Join(Path.DirectorySeparatorChar, segments);
+ return (null, candidate);
+ }
+
+ /// version 合成:size-mtimeTicks(前端透传比较失效即可,无需内容哈希)
+ private string VersionFor(string absolutePath) {
+ var length = TryGetLength(absolutePath) ?? 0;
+ var mtime = fs.GetLastWriteTimeUtc(absolutePath).Ticks;
+ return $"{length}-{mtime}";
+ }
+
+ private long? TryGetLength(string absolutePath) {
+ try {
+ return new FileInfo(absolutePath).Length;
+ } catch (Exception) {
+ return null;
+ }
+ }
+
+ private static DshRpcResult AsInternalError() => DshRpcResults.Fail(
+ "workspaceFiles/rejected", "路径解析失败(内部错误);请刷新文件树后重试");
+}
diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs
new file mode 100644
index 000000000..ac4513607
--- /dev/null
+++ b/native_gui/tests/join_code_gui.tests/web_host/DshWorkspaceFilesTests.cs
@@ -0,0 +1,114 @@
+namespace JoinCode.Gui.Tests.WebHost;
+
+///
+/// workspaceFiles 文件树测试(TASK058 P2-B1):list/read/readBytes/stat 四一元方法,
+/// 根目录 = 会话 cwd(SessionInfo.ProjectPath),路径限制在 cwd 内防穿越,version 用 size-mtime 合成。
+///
+public sealed class DshWorkspaceFilesTests {
+ /// 构造带文件树的会话(cwd 下建子目录与文本文件)
+ private static async Task<(DshHostServices Services, string SessionId, string Cwd)> CreateSessionWithFilesAsync() {
+ var fs = new InMemoryFileSystem();
+ var services = new DshHostServices(fs);
+ await using var transcripts = new State.TranscriptService(fs, "/test/sessions");
+ services.Sessions.AttachTranscripts(transcripts);
+ var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None);
+ var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId;
+ await fs.WriteAllTextAsync("/test/proj/readme.md", "# hello\nline2\nline3\n");
+ await fs.WriteAllTextAsync("/test/proj/app/main.cs", "Console.WriteLine(1);\n");
+ fs.CreateDirectory("/test/proj/docs");
+ return (services, sessionId, "/test/proj");
+ }
+
+ private static JsonElement FilesArgs(string sessionId, string path) => JsonSerializer.SerializeToElement(new {
+ workspaceFileScopeId = sessionId,
+ path,
+ });
+
+ [Fact]
+ public async Task List_ReturnsEntriesSortedWithTypes() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/list", FilesArgs(sessionId, ""));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ value.GetProperty("path").GetString().Should().Be("");
+ var entries = value.GetProperty("entries");
+ entries.GetArrayLength().Should().Be(3);
+ entries[0].GetProperty("name").GetString().Should().Be("app");
+ entries[0].GetProperty("type").GetString().Should().Be("directory");
+ entries[1].GetProperty("name").GetString().Should().Be("docs");
+ entries[2].GetProperty("name").GetString().Should().Be("readme.md");
+ entries[2].GetProperty("type").GetString().Should().Be("file");
+ value.GetProperty("truncated").GetBoolean().Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Read_ReturnsTextWithOffsetAndEof() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "readme.md"));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ value.GetProperty("text").GetString().Should().Contain("# hello");
+ value.GetProperty("lines").GetInt32().Should().Be(4);
+ value.GetProperty("eof").GetBoolean().Should().BeTrue();
+ value.GetProperty("absolutePath").GetString().Should().Contain("readme.md");
+ value.GetProperty("version").GetString().Should().NotBeNullOrEmpty("version=size-mtime 合成");
+ }
+
+ [Fact]
+ public async Task ReadBytes_ReturnsBase64Data() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/readBytes", FilesArgs(sessionId, "readme.md"));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ var data = value.GetProperty("data").GetString()
+ ?? throw new Xunit.Sdk.XunitException("data 缺失");
+ data.Should().NotBeNullOrEmpty("data 为 base64");
+ Convert.FromBase64String(data).Length.Should().BeGreaterThan(0);
+ value.GetProperty("eof").GetBoolean().Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task Stat_ReturnsAbsolutePathAndVersion() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/stat", FilesArgs(sessionId, "app/main.cs"));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ value.GetProperty("absolutePath").GetString().Should().Contain("main.cs");
+ value.GetProperty("version").GetString().Should().NotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task Read_PathTraversal_ReturnsStableError() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "../../etc/passwd"));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("workspaceFiles/rejected");
+ result.Error?.Message.Should().Contain("工作区外", "路径穿越必须被拦截并给出引导");
+ }
+
+ [Fact]
+ public async Task Read_MissingFile_ReturnsNotFound() {
+ var (services, sessionId, _) = await CreateSessionWithFilesAsync();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("workspaceFiles/read", FilesArgs(sessionId, "no-such.txt"));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("workspaceFiles/not-found");
+ }
+}
From 359021044dac6bd664a18e18cbb462b935fb1e9a Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 20:51:48 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat:=20TASK058=20P2-B2=20=E9=99=84?=
=?UTF-8?q?=E4=BB=B6=E9=93=BE=E4=B8=89=E7=AB=AF=E7=82=B9=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20DshAttachmentStore=20receipt=20=E8=90=BD=E7=9B=98?=
=?UTF-8?q?=EF=BC=88bin+json=20=E5=8F=8C=E6=96=87=E4=BB=B6=EF=BC=89?=
=?UTF-8?q?=EF=BC=8CmediaType=20=E9=AD=94=E6=95=B0=E6=8E=A2=E6=B5=8B?=
=?UTF-8?q?=EF=BC=88PNG/JPEG/GIF/WEBP=EF=BC=89=EF=BC=8C=E5=AE=BD=E9=AB=98?=
=?UTF-8?q?=E6=89=8B=E5=86=99=E5=A4=B4=E8=A7=A3=E6=9E=90=EF=BC=88=E5=9B=9B?=
=?UTF-8?q?=E6=A0=BC=E5=BC=8F=20AOT=20=E5=AE=89=E5=85=A8=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?owner=20=E9=98=B2=E7=9B=AE=E5=BD=95=E9=80=83=E9=80=B8+receiptId?=
=?UTF-8?q?=20=E9=98=B2=E6=B3=A8=E5=85=A5=E5=8F=8C=E5=B1=82=E9=98=B2?=
=?UTF-8?q?=E5=BE=A1=EF=BC=9BuploadFileBinary=20octet-stream=20=E7=9B=B4?=
=?UTF-8?q?=E4=BC=A0=E8=B7=AF=E7=94=B1=EF=BC=88=E5=85=8D=20base64=20?=
=?UTF-8?q?=E8=86=A8=E8=83=80=EF=BC=89=EF=BC=9Bprompt=20file=20part=20?=
=?UTF-8?q?=E6=B6=88=E8=B4=B9=20receiptId=20=E5=B1=9E=E5=BC=95=E6=93=8E?=
=?UTF-8?q?=E5=A4=9A=E6=A8=A1=E6=80=81=E8=BE=93=E5=85=A5=E6=B7=B1=E5=8C=96?=
=?UTF-8?q?=E9=A1=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
native_gui/src/NativeGui.Core/GlobalUsings.cs | 1 +
.../web_host/DshAttachmentStore.cs | 202 ++++++++++++++++++
.../web_host/DshHostServices.cs | 4 +
.../web_host/DshRpcDispatcher.cs | 24 +++
.../NativeGui.Core/web_host/DshRpcProtocol.cs | 87 ++++++++
.../src/NativeGui.Core/web_host/DshWebHost.cs | 50 +++++
.../web_host/DshAttachmentTests.cs | 114 ++++++++++
7 files changed, 482 insertions(+)
create mode 100644 native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs
create mode 100644 native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs
diff --git a/native_gui/src/NativeGui.Core/GlobalUsings.cs b/native_gui/src/NativeGui.Core/GlobalUsings.cs
index 25bcf3258..0c2dd2eaa 100644
--- a/native_gui/src/NativeGui.Core/GlobalUsings.cs
+++ b/native_gui/src/NativeGui.Core/GlobalUsings.cs
@@ -32,6 +32,7 @@
global using JoinCode.BclBridge;
global using JoinCode.Gui.Hosting;
global using JoinCode.Gui.Theming;
+global using System.Buffers.Binary;
global using System;
global using System.Collections.Concurrent;
global using System.Collections.Frozen;
diff --git a/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs b/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs
new file mode 100644
index 000000000..3b09ab89f
--- /dev/null
+++ b/native_gui/src/NativeGui.Core/web_host/DshAttachmentStore.cs
@@ -0,0 +1,202 @@
+namespace JoinCode.Gui.WebHost;
+
+///
+/// 附件存储(TASK058 P2-B2):fileUploads/upload 的 receipt 落盘与 session/attachment 的读取。
+/// 存储 = {dir}/{owner}/ 索引 json + {owner}/{receiptId}.bin;mediaType 由魔数探测
+/// (PNG/JPEG/GIF/WEBP 四类,前端 zod 值域);宽高由文件头解析(手写字节读取,AOT 安全零反射)。
+/// receiptId 同时充当 attachmentId(v1 单层寻址,前端两字段透传一致)。
+///
+internal sealed class DshAttachmentStore(IFileSystem fs, string? directory = null) {
+ private readonly string _directory = directory
+ ?? Path.Combine(AppDataPaths.Default.JccDirectory, "dsh_attachments");
+
+ /// 上传:base64 数据 → 魔数校验 → 落盘 → receipt 元数据
+ internal async Task UploadAsync(string owner, string base64Data, string? name, CancellationToken cancellationToken) {
+ byte[] bytes;
+ try {
+ bytes = Convert.FromBase64String(base64Data);
+ } catch (FormatException) {
+ return DshRpcResults.Fail("fileUploads/rejected", "data 不是合法 base64;请用 FileReader.readAsDataURL 的纯 base64 部分");
+ }
+ if (bytes.Length == 0) {
+ return DshRpcResults.Fail("fileUploads/rejected", "附件内容为空;请选择非空文件重试");
+ }
+ var mediaType = SniffMediaType(bytes);
+ if (mediaType is null) {
+ return DshRpcResults.Fail("fileUploads/rejected",
+ "前端仅支持图片附件(image/png、image/jpeg、image/webp、image/gif 四类);请转换格式后重传");
+ }
+ var receiptId = Guid.NewGuid().ToString("N");
+ var ownerDir = Path.Combine(_directory, owner);
+ if (!fs.DirectoryExists(ownerDir)) {
+ fs.CreateDirectory(ownerDir);
+ }
+ await fs.WriteAllBytesAsync(Path.Combine(ownerDir, $"{receiptId}.bin"), bytes, cancellationToken);
+ var record = new DshAttachmentRecord {
+ AttachmentId = receiptId,
+ Name = string.IsNullOrWhiteSpace(name) ? "attachment" : name,
+ MediaType = mediaType,
+ Bytes = bytes.Length,
+ Width = ReadWidth(bytes) ?? 0,
+ Height = ReadHeight(bytes) ?? 0,
+ };
+ await fs.WriteAllTextAsync(Path.Combine(ownerDir, $"{receiptId}.json"),
+ RelaxedJsonSerializer.Serialize(record, DshJsonContext.Default), cancellationToken);
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshFileUploadResult {
+ ReceiptId = receiptId,
+ File = new DshUploadedFileInfo {
+ AttachmentId = receiptId,
+ Name = record.Name,
+ Bytes = record.Bytes,
+ },
+ },
+ DshJsonContext.Default.DshFileUploadResult));
+ }
+
+ /// 读取:receiptId → 元数据 + base64 数据(前端聊天内图片渲染)
+ internal async Task ReadAsync(string owner, string attachmentId, CancellationToken cancellationToken) {
+ if (!IsValidReceiptId(attachmentId) || !IsValidOwner(owner)) {
+ return DshRpcResults.Fail("session/attachment-not-found",
+ $"附件 {attachmentId} 不存在(ID 非法);请重新上传后用新 receiptId 引用");
+ }
+ var recordPath = Path.Combine(_directory, owner, $"{attachmentId}.json");
+ if (!fs.FileExists(recordPath)) {
+ return DshRpcResults.Fail("session/attachment-not-found",
+ $"附件 {attachmentId} 不存在;请重新上传后用新 receiptId 引用");
+ }
+ var record = RelaxedJsonSerializer.Deserialize(
+ await fs.ReadAllTextAsync(recordPath, cancellationToken), DshJsonContext.Default.DshAttachmentRecord);
+ if (record is null) {
+ return DshRpcResults.Fail("session/attachment-not-found", $"附件 {attachmentId} 元数据损坏;请重新上传");
+ }
+ var bytes = await fs.ReadAllBytesAsync(Path.Combine(_directory, owner, $"{attachmentId}.bin"), cancellationToken);
+ return DshRpcResults.Ok(JsonSerializer.SerializeToElement(
+ new DshSessionAttachmentResult {
+ Attachment = new DshAttachmentInfo {
+ AttachmentId = record.AttachmentId,
+ MediaType = record.MediaType,
+ Bytes = record.Bytes,
+ Width = record.Width,
+ Height = record.Height,
+ Name = record.Name,
+ },
+ Data = Convert.ToBase64String(bytes),
+ },
+ DshJsonContext.Default.DshSessionAttachmentResult));
+ }
+
+ /// 魔数探测(PNG/JPEG/GIF/WEBP;null=非四类图片)
+ internal static string? SniffMediaType(ReadOnlySpan bytes) {
+ if (bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == (byte)'P' && bytes[2] == (byte)'N' && bytes[3] == (byte)'G') {
+ return "image/png";
+ }
+ if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) {
+ return "image/jpeg";
+ }
+ if (bytes.Length >= 6 && bytes[0] == (byte)'G' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F') {
+ return "image/gif";
+ }
+ if (bytes.Length >= 12 && bytes[0] == (byte)'R' && bytes[1] == (byte)'I' && bytes[2] == (byte)'F'
+ && bytes[8] == (byte)'W' && bytes[9] == (byte)'E' && bytes[10] == (byte)'B' && bytes[11] == (byte)'P') {
+ return "image/webp";
+ }
+ return null;
+ }
+
+ /// 读图片宽(PNG IHDER / GIF 头 / WEBP VP8X|VP8|VP8L;JPEG 扫 SOF 段)
+ internal static int? ReadWidth(ReadOnlySpan bytes) => ReadDimensions(bytes)?.Width;
+
+ /// 读图片高
+ internal static int? ReadHeight(ReadOnlySpan bytes) => ReadDimensions(bytes)?.Height;
+
+ /// 四格式尺寸头解析(手写字节读取,AOT 安全;解析失败返回 null)
+ private static (int Width, int Height)? ReadDimensions(ReadOnlySpan b) {
+ if (b.Length >= 24 && b[1] == (byte)'P' && b[2] == (byte)'N' && b[3] == (byte)'G') {
+ // PNG IHDER:宽高在 16..23(大端)
+ return (BinaryPrimitives.ReadInt32BigEndian(b[16..20]), BinaryPrimitives.ReadInt32BigEndian(b[20..24]));
+ }
+ if (b.Length >= 10 && b[0] == (byte)'G' && b[1] == (byte)'I' && b[2] == (byte)'F') {
+ // GIF 头:宽高在 6..9(小端 ushort)
+ return (BinaryPrimitives.ReadUInt16LittleEndian(b[6..8]), BinaryPrimitives.ReadUInt16LittleEndian(b[8..10]));
+ }
+ if (b.Length >= 30 && b[0] == 0xFF && b[1] == 0xD8) {
+ return ReadJpegDimensions(b);
+ }
+ if (b.Length >= 30 && b[0] == (byte)'R' && b[1] == (byte)'I' && b[2] == (byte)'F') {
+ return ReadWebpDimensions(b);
+ }
+ return null;
+ }
+
+ /// JPEG 尺寸:扫 SOF0-SOF15 段(跳过 FFE0-FFEF 应用段)
+ private static (int, int)? ReadJpegDimensions(ReadOnlySpan b) {
+ var offset = 2;
+ while (offset + 9 < b.Length) {
+ if (b[offset] != 0xFF) {
+ return null;
+ }
+ var marker = b[offset + 1];
+ if (marker is 0xD8 or 0x01 or (>= 0xD0 and <= 0xD7)) {
+ offset += 2;
+ continue;
+ }
+ var segmentLength = BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 2)..]);
+ if (marker is >= 0xC0 and <= 0xCF and not 0xC4 and not 0xC8 and not 0xCC) {
+ if (offset + 9 >= b.Length) {
+ return null;
+ }
+ // SOF:高在 +5..+7,宽在 +7..+9(大端)
+ return (BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 7)..(offset + 9)]),
+ BinaryPrimitives.ReadUInt16BigEndian(b[(offset + 5)..(offset + 7)]));
+ }
+ offset += 2 + segmentLength;
+ }
+ return null;
+ }
+
+ /// WEBP 尺寸:VP8X(广域)/VP8(有损 keyframe)/VP8L(无损)三分支
+ private static (int, int)? ReadWebpDimensions(ReadOnlySpan b) {
+ var chunk = b[12..];
+ var tag = chunk.Length >= 4 ? System.Text.Encoding.ASCII.GetString(chunk[..4]) : "";
+ switch (tag) {
+ case "VP8X": {
+ if (chunk.Length < 14) {
+ return null;
+ }
+ // VP8X:canvas 宽=1+24bit(b[4..7] 低 24 位),高=1+24bit(b[7..10])
+ var width = 1 + (chunk[4] | chunk[5] << 8 | chunk[6] << 16);
+ var height = 1 + (chunk[7] | chunk[8] << 8 | chunk[9] << 16);
+ return (width, height);
+ }
+ case "VP8 ": {
+ if (chunk.Length < 14) {
+ return null;
+ }
+ // VP8 keyframe:宽在 chunk[6..8]、高在 chunk[8..10](小端 ushort)
+ return (BinaryPrimitives.ReadUInt16LittleEndian(chunk[6..8]),
+ BinaryPrimitives.ReadUInt16LittleEndian(chunk[8..10]));
+ }
+ case "VP8L": {
+ if (chunk.Length < 9) {
+ return null;
+ }
+ // VP8L:bits[0..13]=宽-1,bits[14..27]=高-1(28 位打包小端位序)
+ var bits = BinaryPrimitives.ReadUInt32LittleEndian(chunk[4..8]);
+ return ((int)(bits & 0x3FFF) + 1, (int)(bits >> 14 & 0x3FFF) + 1);
+ }
+ default:
+ return null;
+ }
+ }
+
+ /// receipt/attachment ID 合法性(hex 32 位 GUID — 防路径注入)
+ private static bool IsValidReceiptId(string id) =>
+ id.Length == 32 && id.All(char.IsAsciiHexDigit);
+
+ /// owner(会话/代理 ID)合法性:非空且不含路径分隔符与 .. 段(防目录逃逸;形状由前端会话体系决定,不限定 hex)
+ private static bool IsValidOwner(string owner) =>
+ !string.IsNullOrWhiteSpace(owner)
+ && owner.IndexOfAny([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, ':']) < 0
+ && !owner.Contains("..");
+}
diff --git a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
index 1db42d64b..2e4ec0979 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshHostServices.cs
@@ -19,6 +19,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul
Streams = new DshSessionStreamHub(Journal);
Approvals = new DshApprovalBroker();
WorkspaceFiles = new DshWorkspaceFilesService(Sessions, fileSystem);
+ Attachments = new DshAttachmentStore(fileSystem);
}
/// 自有引擎会话门面(组装完成后注入;未就绪时发送消息返回引导错误)
@@ -51,4 +52,7 @@ internal DshHostServices(IFileSystem fileSystem, string? workspaceFilePath = nul
/// 工作区文件树(workspaceFiles/list|read|readBytes|stat,TASK058 P2-B1)
internal DshWorkspaceFilesService WorkspaceFiles { get; }
+
+ /// 附件存储(fileUploads/upload 与 session/attachment,TASK058 P2-B2)
+ internal DshAttachmentStore Attachments { get; }
}
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
index 12a78ea11..09cc52837 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
@@ -69,6 +69,9 @@ internal DshRpcDispatcher(DshHostServices services) {
["workspaceFiles/read"] = WorkspaceFilesRead,
["workspaceFiles/readBytes"] = WorkspaceFilesReadBytes,
["workspaceFiles/stat"] = WorkspaceFilesStat,
+ // 附件链(TASK058 P2-B2;uploadFileBinary 直传路由在 DshWebHost octet-stream 分支)
+ ["fileUploads/upload"] = FileUploadsUpload,
+ ["session/attachment"] = SessionAttachment,
}.ToFrozenDictionary(StringComparer.Ordinal);
}
@@ -398,6 +401,27 @@ private async Task WorkspaceFilesStat(JsonElement args, Cancellati
return await _services.WorkspaceFiles.StatAsync(sessionId, path, cancellationToken);
}
+ /// fileUploads/upload:base64 附件 → 魔数校验 → receipt 落盘(TASK058 P2-B2)
+ private async Task FileUploadsUpload(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshFileUploadsUploadArgs);
+ if (request is null || string.IsNullOrWhiteSpace(request.AgentId) || request.Request is null
+ || string.IsNullOrEmpty(request.Request.Data)) {
+ return Fail("fileUploads/rejected",
+ $"fileUploads/upload 需要 {{agentId, request:{{data, name?}}}};收到 args={Describe(args)}");
+ }
+ return await _services.Attachments.UploadAsync(request.AgentId, request.Request.Data, request.Request.Name, cancellationToken);
+ }
+
+ /// session/attachment:读附件(元数据 + base64 数据,前端聊天内图片渲染)
+ private async Task SessionAttachment(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshSessionAttachmentArgs);
+ if (request is null || string.IsNullOrWhiteSpace(request.SessionId) || string.IsNullOrWhiteSpace(request.AttachmentId)) {
+ return Fail("session/attachment-not-found",
+ $"session/attachment 需要 {{sessionId, attachmentId}};收到 args={Describe(args)}");
+ }
+ return await _services.Attachments.ReadAsync(request.SessionId, request.AttachmentId, cancellationToken);
+ }
+
/// commands/list:引擎斜杠命令目录投影(TASK058 B5 — 引擎 [ChatCommand] 特性为唯一数据源)
private DshRpcResult CommandsList(JsonElement args) => Ok(
(_services.Engine?.GetAvailableSlashCommands() ?? [])
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
index 47353aed8..d0342b39c 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
@@ -141,6 +141,88 @@ internal sealed class DshEventErrorBody {
/// $events/result 回帖响应(前端只看 ok,无业务载荷)
internal sealed class DshEventsResultValue;
+/// fileUploads/upload 参数(前端 lookup 字段 agentId 即会话/代理 ID;request 包裹 data+name)
+internal sealed class DshFileUploadsUploadArgs {
+ /// 目标会话/代理
+ public string AgentId { get; set; } = "";
+ /// 上传请求体
+ public DshFileUploadRequest? Request { get; set; }
+}
+
+/// 上传请求体
+internal sealed class DshFileUploadRequest {
+ /// base64 数据
+ public string Data { get; set; } = "";
+ /// 文件名(可选)
+ public string? Name { get; set; }
+}
+
+/// fileUploads/upload 响应(receiptId 进 prompt 的 file part)
+internal sealed class DshFileUploadResult {
+ /// 回执 ID(同时是 attachmentId)
+ public string ReceiptId { get; set; } = "";
+ /// 文件元数据
+ public DshUploadedFileInfo File { get; set; } = new();
+}
+
+/// 上传文件元数据
+internal sealed class DshUploadedFileInfo {
+ /// 附件 ID
+ public string AttachmentId { get; set; } = "";
+ /// 文件名
+ public string Name { get; set; } = "";
+ /// 字节数
+ public int Bytes { get; set; }
+}
+
+/// 附件落盘记录({receiptId}.json)
+internal sealed class DshAttachmentRecord {
+ /// 附件 ID
+ public string AttachmentId { get; set; } = "";
+ /// 文件名
+ public string Name { get; set; } = "";
+ /// 媒体类型(四类图片)
+ public string MediaType { get; set; } = "";
+ /// 字节数
+ public int Bytes { get; set; }
+ /// 宽
+ public int Width { get; set; }
+ /// 高
+ public int Height { get; set; }
+}
+
+/// session/attachment 参数
+internal sealed class DshSessionAttachmentArgs {
+ /// 会话/代理(上传时的 owner)
+ public string SessionId { get; set; } = "";
+ /// 附件 ID
+ public string AttachmentId { get; set; } = "";
+}
+
+/// session/attachment 响应(前端聊天内图片渲染)
+internal sealed class DshSessionAttachmentResult {
+ /// 附件元数据
+ public DshAttachmentInfo Attachment { get; set; } = new();
+ /// base64 数据
+ public string Data { get; set; } = "";
+}
+
+/// 附件元数据
+internal sealed class DshAttachmentInfo {
+ /// 附件 ID
+ public string AttachmentId { get; set; } = "";
+ /// 媒体类型
+ public string MediaType { get; set; } = "";
+ /// 字节数
+ public int Bytes { get; set; }
+ /// 宽
+ public int Width { get; set; }
+ /// 高
+ public int Height { get; set; }
+ /// 文件名
+ public string? Name { get; set; }
+}
+
/// workspaceFiles 四方法共用参数(前端 lookup 字段 workspaceFileScopeId 即 sessionId)
internal sealed class DshWorkspaceFilesArgs {
/// 会话 ID(文件树根 = 会话 cwd)
@@ -1313,5 +1395,10 @@ internal sealed class DshStreamErrorBody {
[JsonSerializable(typeof(DshWorkspaceFilesReadResult))]
[JsonSerializable(typeof(DshWorkspaceFilesReadBytesResult))]
[JsonSerializable(typeof(DshWorkspaceFilesStatResult))]
+[JsonSerializable(typeof(DshFileUploadsUploadArgs))]
+[JsonSerializable(typeof(DshFileUploadResult))]
+[JsonSerializable(typeof(DshAttachmentRecord))]
+[JsonSerializable(typeof(DshSessionAttachmentArgs))]
+[JsonSerializable(typeof(DshSessionAttachmentResult))]
[JsonSerializable(typeof(object))]
internal sealed partial class DshJsonContext : JsonSerializerContext;
diff --git a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs
index 85ffe54a2..f2f123be9 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshWebHost.cs
@@ -16,6 +16,7 @@ internal sealed class DshWebHost : IAsyncDisposable {
private const string StreamPath = "/api/remote.mux";
private const string ApiPrefix = "/api/";
private const string ComboPrefix = "/plugins/??";
+ private const string BinaryUploadPath = "/api/session/uploadFileBinary";
private static readonly FrozenDictionary ContentTypes =
new Dictionary(StringComparer.OrdinalIgnoreCase) {
@@ -102,6 +103,11 @@ private async Task RouteAsync(HttpListenerContext context) {
}
var path = request.Url?.AbsolutePath ?? "/";
if (path.StartsWith(ApiPrefix, StringComparison.Ordinal) || path == "/api") {
+ // 附件直传(TASK058 P2-B2):POST /api/session/uploadFileBinary?sessionId=&name=,body 为 octet-stream
+ if (string.Equals(path, BinaryUploadPath, StringComparison.Ordinal)) {
+ await HandleBinaryUploadAsync(context);
+ return;
+ }
await HandleRpcAsync(context);
return;
}
@@ -112,6 +118,50 @@ private async Task RouteAsync(HttpListenerContext context) {
await ServeFileAsync(context, path.TrimStart('/'));
}
+ ///
+ /// 附件二进制直传:body 原样字节(Blob/流式大文件免 base64 膨胀),魔数校验与落盘复用
+ /// ;响应与 RPC 同信封(前端 parseFileUploadResult 统一解析)。
+ ///
+ private async Task HandleBinaryUploadAsync(HttpListenerContext context) {
+ var response = context.Response;
+ try {
+ var query = context.Request.Url?.Query ?? "";
+ var sessionId = QueryValue(query, "sessionId");
+ var name = QueryValue(query, "name");
+ DshRpcResult result;
+ if (string.IsNullOrWhiteSpace(sessionId)) {
+ result = DshRpcResults.Fail("fileUploads/rejected", "uploadFileBinary 需要 sessionId 与 name 查询参数");
+ } else {
+ using var buffer = new MemoryStream();
+ await context.Request.InputStream.CopyToAsync(buffer);
+ result = await _services.Attachments.UploadAsync(sessionId, Convert.ToBase64String(buffer.ToArray()), name, _lifetime.Token);
+ }
+ var reply = new DshRpcResponse { RpcId = "upload", Result = result };
+ var payload = JsonSerializer.SerializeToUtf8Bytes(reply, DshJsonContext.Default.DshRpcResponse);
+ response.StatusCode = 200;
+ response.ContentType = "application/json; charset=utf-8";
+ response.Headers["Cache-Control"] = "no-store";
+ response.ContentLength64 = payload.Length;
+ await response.OutputStream.WriteAsync(payload);
+ } catch (Exception ex) {
+ GuiDiag.Log($"[DSH-WEB] binary upload failed: {ex.Message}");
+ TryClose(response, 500);
+ } finally {
+ TryClose(response, 200);
+ }
+ }
+
+ /// 解析查询串单值(无解码需求:sessionId/name 均为简单 ASCII)
+ private static string? QueryValue(string query, string key) {
+ foreach (var pair in query.TrimStart('?').Split('&')) {
+ var equals = pair.IndexOf('=');
+ if (equals > 0 && string.Equals(pair[..equals], key, StringComparison.OrdinalIgnoreCase)) {
+ return pair[(equals + 1)..];
+ }
+ }
+ return null;
+ }
+
/// 一元 RPC:解析信封 → 分发 → 回写信封(失败也回 200,错误在 result.error)
private async Task HandleRpcAsync(HttpListenerContext context) {
var response = context.Response;
diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs
new file mode 100644
index 000000000..f9df361e8
--- /dev/null
+++ b/native_gui/tests/join_code_gui.tests/web_host/DshAttachmentTests.cs
@@ -0,0 +1,114 @@
+namespace JoinCode.Gui.Tests.WebHost;
+
+///
+/// 附件链测试(TASK058 P2-B2):fileUploads/upload → receipt 落盘 → session/attachment 读取。
+/// mediaType 魔数探测(PNG/JPEG/GIF/WEBP)+ 尺寸头解析;非四类图片拒绝上传(前端 zod 值域如此)。
+///
+public sealed class DshAttachmentTests {
+ /// 1x1 红色 PNG 字节(合法 PNG:签名+IHDR+IDAT+IEND)
+ private static readonly byte[] PngBytes = Convert.FromBase64String(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
+
+ /// GIF89a 1x1 透明像素
+ private static readonly byte[] GifBytes = Convert.FromBase64String(
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7");
+
+ /// 非图片字节(文本)
+ private static readonly byte[] TextBytes = "hello attachment"u8.ToArray();
+
+ private static (DshHostServices Services, string AgentId) CreateServices() {
+ var services = new DshHostServices(new InMemoryFileSystem());
+ return (services, "agent-1");
+ }
+
+ private static JsonElement UploadArgs(string agentId, byte[] data, string? name = "pic.png") =>
+ JsonSerializer.SerializeToElement(new {
+ agentId,
+ request = new { data = Convert.ToBase64String(data), name },
+ });
+
+ [Fact]
+ public async Task Upload_Png_ReturnsReceiptAndFileMeta() {
+ var (services, agentId) = CreateServices();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, PngBytes));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ value.GetProperty("receiptId").GetString().Should().NotBeNullOrEmpty();
+ var file = value.GetProperty("file");
+ file.GetProperty("attachmentId").GetString().Should().NotBeNullOrEmpty();
+ file.GetProperty("name").GetString().Should().Be("pic.png");
+ file.GetProperty("bytes").GetInt32().Should().Be(PngBytes.Length);
+ }
+
+ [Fact]
+ public async Task Upload_NonImage_ReturnsStableRejection() {
+ var (services, agentId) = CreateServices();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, TextBytes, "note.txt"));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("fileUploads/rejected");
+ result.Error?.Message.Should().Contain("图片", "前端 zod 值域仅四种图片类型");
+ }
+
+ [Fact]
+ public async Task Attachment_RoundTrips_PngWithDimensions() {
+ var (services, agentId) = CreateServices();
+ var dispatcher = new DshRpcDispatcher(services);
+ var upload = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, PngBytes));
+ var receiptId = upload.Value.GetValueOrDefault().GetProperty("receiptId").GetString()
+ ?? throw new Xunit.Sdk.XunitException("upload 未返回 receiptId");
+
+ var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new {
+ sessionId = agentId,
+ attachmentId = receiptId,
+ }));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ var value = result.Value.GetValueOrDefault();
+ var attachment = value.GetProperty("attachment");
+ attachment.GetProperty("mediaType").GetString().Should().Be("image/png");
+ attachment.GetProperty("bytes").GetInt32().Should().Be(PngBytes.Length);
+ attachment.GetProperty("width").GetInt32().Should().Be(1);
+ attachment.GetProperty("height").GetInt32().Should().Be(1);
+ var data = value.GetProperty("data").GetString()
+ ?? throw new Xunit.Sdk.XunitException("data 缺失");
+ Convert.FromBase64String(data).Should().Equal(PngBytes);
+ }
+
+ [Fact]
+ public async Task Attachment_GifDetected() {
+ var (services, agentId) = CreateServices();
+ var dispatcher = new DshRpcDispatcher(services);
+ var upload = await dispatcher.HandleAsync("fileUploads/upload", UploadArgs(agentId, GifBytes, "dot.gif"));
+ var receiptId = upload.Value.GetValueOrDefault().GetProperty("receiptId").GetString()
+ ?? throw new Xunit.Sdk.XunitException("upload 未返回 receiptId");
+
+ var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new {
+ sessionId = agentId,
+ attachmentId = receiptId,
+ }));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ result.Value.GetValueOrDefault().GetProperty("attachment").GetProperty("mediaType").GetString()
+ .Should().Be("image/gif");
+ }
+
+ [Fact]
+ public async Task Attachment_UnknownId_ReturnsNotFound() {
+ var (services, agentId) = CreateServices();
+ var dispatcher = new DshRpcDispatcher(services);
+
+ var result = await dispatcher.HandleAsync("session/attachment", JsonSerializer.SerializeToElement(new {
+ sessionId = agentId,
+ attachmentId = "no-such-receipt",
+ }));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("session/attachment-not-found");
+ }
+}
From e0ff896c6006e339bce93011776124dde8013e58 Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 21:11:03 +0800
Subject: [PATCH 3/4] =?UTF-8?q?feat:=20TASK058=20P2-B3=20session/updateQue?=
=?UTF-8?q?ue=20=E9=98=9F=E5=88=97=E7=BC=96=E8=BE=91=20|=20=E5=86=B3?=
=?UTF-8?q?=E7=AD=96:=20PendingPrompt=20=E5=8A=A0=20ItemId=20=E5=8F=A5?=
=?UTF-8?q?=E6=9F=84=EF=BC=8Cremove/edit=20=E9=94=81=E5=86=85=E9=87=8D?=
=?UTF-8?q?=E5=BB=BA=E9=98=9F=E5=88=97=EF=BC=88=E4=BD=8E=E9=A2=91=E4=BA=A4?=
=?UTF-8?q?=E4=BA=92=EF=BC=89=EF=BC=9Bsteer=20=E8=AF=9A=E5=AE=9E=E8=BF=94?=
=?UTF-8?q?=E5=9B=9E=20session/steer-unavailable=EF=BC=88=E5=BC=95?=
=?UTF-8?q?=E6=93=8E=E6=97=A0=E8=BD=AE=E5=86=85=E6=B3=A8=E5=85=A5=E7=82=B9?=
=?UTF-8?q?=EF=BC=8C=E6=B6=88=E6=81=AF=E4=BF=9D=E6=8C=81=E6=8E=92=E9=98=9F?=
=?UTF-8?q?=E4=B8=8B=E4=B8=80=E8=BD=AE=E6=89=A7=E8=A1=8C=EF=BC=89=EF=BC=9B?=
=?UTF-8?q?PlaceholderChatSession=20=E5=8A=A0=20TurnGate=20=E6=B5=8B?=
=?UTF-8?q?=E8=AF=95=E6=8E=A5=E7=BC=9D=EF=BC=88busy=20=E5=8D=A0=E7=94=A8?=
=?UTF-8?q?=E7=A1=AE=E5=AE=9A=E6=80=A7=E9=AA=8C=E8=AF=81=EF=BC=89+=20270/2?=
=?UTF-8?q?70=20=E7=BB=BF?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...344\270\216DTO\347\273\237\344\270\200.md" | 13 +-
.../hosting/PlaceholderChatSession.cs | 6 +
.../web_host/DshRpcDispatcher.cs | 42 ++++++
.../NativeGui.Core/web_host/DshRpcProtocol.cs | 26 ++++
.../NativeGui.Core/web_host/DshTurnRunner.cs | 74 +++++++++-
.../web_host/DshUpdateQueueTests.cs | 129 ++++++++++++++++++
6 files changed, 284 insertions(+), 6 deletions(-)
create mode 100644 native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs
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 2e6322779..4ff3843a7 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"
@@ -31,8 +31,17 @@
| 10 | 逐字流式 | `assistant-stream` 恒 revision 0 | TASK043 已登记(引擎侧缺陷) |
| 11 | steer | `mode≠queue` 返回 `session/busy` | 引擎未暴露轮内注入(0945d7d32 前提记录) |
-### P2 — 大块新功能(另行立项)
-附件(fileUploads/attachment)、文件树(workspaceFiles 5)、终端(10)、goals 6、`session/updateQueue`。
+### P2 — 大块新功能
+
+| # | 功能 | 状态 |
+|---|------|------|
+| 12 | 文件树 workspaceFiles/list\|read\|readBytes\|stat | ✅ 真机(弹栈式防穿越+version 合成;changes 流深化项) |
+| 13 | 附件 fileUploads/upload + session/attachment + uploadFileBinary 直传 | ✅ 真机(魔数探测+四格式尺寸头解析;prompt file part 消费属引擎多模态深化) |
+| 14 | 队列 session/updateQueue(edit/remove) | ✅ 单元 4/4+真机契约(steer 诚实返回 steer-unavailable;队列可见性 inbox 投影深化) |
+| 15 | 终端(10 方法) | ⬜ 需引擎大扩展(ConPTY/屏幕缓冲/多 shell),另行立项 |
+| 16 | goals(7 方法+事件) | ⬜ 需引擎中扩展(clock/goal 生命周期模型映射+revision CAS 适配),另行立项 |
+| 17 | steer / prompt mode:"steer" | ⬜ 需引擎大扩展(StreamAsync 轮内注入点),另行立项 |
+| 18 | workspaceFiles/changes 流 | ⬜ watcher 桥接深化项 |
### 不接 ❌(DSH 在线服务,与本项目无关)
account(11)、pluginManager(12)、dynamicCordisRunner(9)、productAnalytics、schedule、officeToPdf —— 保持 `unimplemented` 引导错误。
diff --git a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs
index b944fbc66..58be0ee84 100644
--- a/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs
+++ b/native_gui/src/NativeGui.Core/hosting/PlaceholderChatSession.cs
@@ -164,10 +164,16 @@ public Task ExecuteSlashCommandAsync(string input, CancellationToken can
/// 最后一次执行的斜杠命令 — 测试追踪字段
public string? LastExecutedSlashCommand { get; private set; }
+ /// 轮次闸门(测试接缝):StreamAsync 开头等待此委托返回;null=立即生成。用于测试保持 busy 占用
+ internal Func? TurnGate { get; set; }
+
/// 占位会话流式回显 — 产出思考/工具/正文示例事件供 UI 渲染验证
public async IAsyncEnumerable StreamAsync(
string message,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) {
+ if (TurnGate is { } gate) {
+ await gate(cancellationToken);
+ }
foreach (var ch in "让我先分析一下你的问题。") {
cancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
index 09cc52837..e279f5c92 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcDispatcher.cs
@@ -72,6 +72,8 @@ internal DshRpcDispatcher(DshHostServices services) {
// 附件链(TASK058 P2-B2;uploadFileBinary 直传路由在 DshWebHost octet-stream 分支)
["fileUploads/upload"] = FileUploadsUpload,
["session/attachment"] = SessionAttachment,
+ // 队列编辑(TASK058 P2-B3;steer=引擎无注入点诚实拒绝)
+ ["session/updateQueue"] = SessionUpdateQueue,
}.ToFrozenDictionary(StringComparer.Ordinal);
}
@@ -412,6 +414,45 @@ private async Task FileUploadsUpload(JsonElement args, Cancellatio
return await _services.Attachments.UploadAsync(request.AgentId, request.Request.Data, request.Request.Name, cancellationToken);
}
+ /// 会话队列快照透传(updateQueue 响应与测试消费;internal 接缝)
+ internal IReadOnlyList ListQueue(string sessionId) => _turns.ListQueue(sessionId);
+
+ ///
+ /// session/updateQueue:排队项编辑(TASK058 P2-B3)。edit=重写文本,remove=移除;
+ /// steer 返回 session/steer-unavailable(引擎 StreamAsync 一轮独占无轮内注入点 — 诚实引导,
+ /// 消息保持排队将在下一轮执行)。行不在队列/已被 claim 返回 session/queue-item-not-found。
+ ///
+ private Task SessionUpdateQueue(JsonElement args, CancellationToken cancellationToken) {
+ var request = args.Deserialize(DshJsonContext.Default.DshUpdateQueueArgs);
+ if (request is null || string.IsNullOrWhiteSpace(request.SessionId)
+ || string.IsNullOrWhiteSpace(request.ItemId) || request.Action is null) {
+ return Task.FromResult(Fail("session/rejected",
+ $"session/updateQueue 需要 {{sessionId, itemId, action:{{kind, content?}}}};收到 args={Describe(args)}"));
+ }
+ if (string.Equals(request.Action.Kind, "steer", StringComparison.Ordinal)) {
+ return Task.FromResult(Fail("session/steer-unavailable",
+ "引擎暂不支持轮内注入(steer);该消息保持排队,将在当前轮结束后的下一轮执行"));
+ }
+ if (string.Equals(request.Action.Kind, "remove", StringComparison.Ordinal)) {
+ return Task.FromResult(_turns.TryRemoveQueueItem(request.SessionId, request.ItemId)
+ ? Ok(new DshQueueAcceptedResult())
+ : Fail("session/queue-item-not-found",
+ $"队列项 {request.ItemId} 不在队列中(可能已被下一轮 claim);刷新队列后重试"));
+ }
+ if (string.Equals(request.Action.Kind, "edit", StringComparison.Ordinal)) {
+ var text = string.Concat(request.Action.Content?.Select(part => part.Text ?? "") ?? []);
+ if (string.IsNullOrWhiteSpace(text)) {
+ return Task.FromResult(Fail("session/rejected", "edit 的 content 需要非空文本"));
+ }
+ return Task.FromResult(_turns.TryEditQueueItem(request.SessionId, request.ItemId, text)
+ ? Ok(new DshQueueAcceptedResult())
+ : Fail("session/queue-item-not-found",
+ $"队列项 {request.ItemId} 不在队列中(可能已被下一轮 claim);刷新队列后重试"));
+ }
+ return Task.FromResult(Fail("session/rejected",
+ $"updateQueue 的 action.kind 必须是 edit/remove/steer 之一;收到 {request.Action.Kind}"));
+ }
+
/// session/attachment:读附件(元数据 + base64 数据,前端聊天内图片渲染)
private async Task SessionAttachment(JsonElement args, CancellationToken cancellationToken) {
var request = args.Deserialize(DshJsonContext.Default.DshSessionAttachmentArgs);
@@ -487,6 +528,7 @@ private async Task PermissionPresetsCatalog(JsonElement args, Canc
});
}
+ private static DshRpcResult Ok(DshQueueAcceptedResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshQueueAcceptedResult));
private static DshRpcResult Ok(DshSessionList value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionList));
private static DshRpcResult Ok(DshSessionRenameResult value) => Success(JsonSerializer.SerializeToElement(value, DshJsonContext.Default.DshSessionRenameResult));
diff --git a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
index d0342b39c..7e3f167c7 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshRpcProtocol.cs
@@ -223,6 +223,30 @@ internal sealed class DshAttachmentInfo {
public string? Name { get; set; }
}
+/// session/updateQueue 参数(QueueDock 行操作)
+internal sealed class DshUpdateQueueArgs {
+ /// 会话 ID
+ public string SessionId { get; set; } = "";
+ /// 队列项 ID(入队时分配的 GUID)
+ public string ItemId { get; set; } = "";
+ /// 操作(edit/remove/steer)
+ public DshUpdateQueueAction? Action { get; set; }
+}
+
+/// 队列操作
+internal sealed class DshUpdateQueueAction {
+ /// 操作类型
+ public string Kind { get; set; } = "";
+ /// edit 的新内容(text part 数组;其他 kind 恒空)
+ public List Content { get; set; } = [];
+}
+
+/// updateQueue 受理响应
+internal sealed class DshQueueAcceptedResult {
+ /// 受理标志
+ public bool Accepted => true;
+}
+
/// workspaceFiles 四方法共用参数(前端 lookup 字段 workspaceFileScopeId 即 sessionId)
internal sealed class DshWorkspaceFilesArgs {
/// 会话 ID(文件树根 = 会话 cwd)
@@ -1400,5 +1424,7 @@ internal sealed class DshStreamErrorBody {
[JsonSerializable(typeof(DshAttachmentRecord))]
[JsonSerializable(typeof(DshSessionAttachmentArgs))]
[JsonSerializable(typeof(DshSessionAttachmentResult))]
+[JsonSerializable(typeof(DshUpdateQueueArgs))]
+[JsonSerializable(typeof(DshQueueAcceptedResult))]
[JsonSerializable(typeof(object))]
internal sealed partial class DshJsonContext : JsonSerializerContext;
diff --git a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs
index b488bfca5..8424a47b2 100644
--- a/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs
+++ b/native_gui/src/NativeGui.Core/web_host/DshTurnRunner.cs
@@ -40,12 +40,12 @@ internal sealed class DshTurnRunner {
_services.Streams.Observe(request.SessionId, snapshot.Cursor, MaxTurn(snapshot.Records));
if (_services.Streams.TryBeginTurn(request.SessionId) is not { } turn) {
if (string.Equals(request.Mode, "queue", StringComparison.Ordinal)) {
- Queue(request.SessionId, new PendingPrompt(request.RequestId, text));
+ Queue(request.SessionId, NewPending(request.RequestId, text));
return null;
}
return ("session/busy", $"会话 {request.SessionId} 上一轮仍在生成;等它结束(接口完成/停止)后再发送,排队请用 mode=queue");
}
- await BeginTurnAsync(engine, request.SessionId, new PendingPrompt(request.RequestId, text), turn);
+ await BeginTurnAsync(engine, request.SessionId, NewPending(request.RequestId, text), turn);
return null;
}
@@ -66,10 +66,14 @@ private async Task BeginTurnAsync(Hosting.IJccChatSession engine, string session
_ = RunTurnAsync(engine, sessionId, prompt.Text, turn.Turn, turn.Token);
}
- /// 把输入放进会话队列
+ /// 把输入放进会话队列(ItemId = GUID,前端 updateQueue 的行操作句柄)
private void Queue(string sessionId, PendingPrompt prompt) =>
_pending.GetOrAdd(sessionId, _ => new System.Collections.Concurrent.ConcurrentQueue()).Enqueue(prompt);
+ /// 构造带操作句柄的排队项
+ private static PendingPrompt NewPending(string requestId, string text) =>
+ new(Guid.NewGuid().ToString("N"), requestId, text);
+
/// 轮次收尾后启动下一条排队输入(无排队则返回)
private void StartQueued(Hosting.IJccChatSession engine, string sessionId) {
if (!_pending.TryGetValue(sessionId, out var queue) || !queue.TryDequeue(out var prompt)) {
@@ -240,5 +244,67 @@ private static int MaxTurn(IReadOnlyList records) =>
/// 排队中的一条用户输入
/// 提交身份(回填 user/message 的 source.rpcId)
/// 已校验文本
- private sealed record PendingPrompt(string RequestId, string Text);
+ private sealed record PendingPrompt(string ItemId, string RequestId, string Text);
+
+ /// 队列项快照(ListQueue 供 updateQueue 与测试消费)
+ internal sealed record QueueItem(string ItemId, string RequestId, string Text);
+
+ /// 会话队列快照(按入队顺序)
+ internal IReadOnlyList ListQueue(string sessionId) {
+ if (!_pending.TryGetValue(sessionId, out var queue)) {
+ return [];
+ }
+ var snapshot = new List();
+ foreach (var prompt in queue) {
+ snapshot.Add(new QueueItem(prompt.ItemId, prompt.RequestId, prompt.Text));
+ }
+ return snapshot;
+ }
+
+ /// 编辑排队项文本(无该项返回 false)
+ internal bool TryEditQueueItem(string sessionId, string itemId, string text) {
+ if (!_pending.TryGetValue(sessionId, out var queue)) {
+ return false;
+ }
+ lock (queue) {
+ var rewritten = false;
+ var rebuilt = new System.Collections.Concurrent.ConcurrentQueue();
+ foreach (var prompt in queue) {
+ if (string.Equals(prompt.ItemId, itemId, StringComparison.Ordinal)) {
+ rebuilt.Enqueue(prompt with { Text = text });
+ rewritten = true;
+ } else {
+ rebuilt.Enqueue(prompt);
+ }
+ }
+ if (!rewritten) {
+ return false;
+ }
+ _pending[sessionId] = rebuilt;
+ return true;
+ }
+ }
+
+ /// 移除排队项(无该项返回 false)
+ internal bool TryRemoveQueueItem(string sessionId, string itemId) {
+ if (!_pending.TryGetValue(sessionId, out var queue)) {
+ return false;
+ }
+ lock (queue) {
+ var removed = false;
+ var rebuilt = new System.Collections.Concurrent.ConcurrentQueue();
+ foreach (var prompt in queue) {
+ if (string.Equals(prompt.ItemId, itemId, StringComparison.Ordinal)) {
+ removed = true;
+ } else {
+ rebuilt.Enqueue(prompt);
+ }
+ }
+ if (!removed) {
+ return false;
+ }
+ _pending[sessionId] = rebuilt;
+ return true;
+ }
+ }
}
diff --git a/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs b/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs
new file mode 100644
index 000000000..43d2a6ea6
--- /dev/null
+++ b/native_gui/tests/join_code_gui.tests/web_host/DshUpdateQueueTests.cs
@@ -0,0 +1,129 @@
+namespace JoinCode.Gui.Tests.WebHost;
+
+///
+/// session/updateQueue 队列编辑测试(TASK058 P2-B3):mode=queue 排队项的 remove/edit 随机访问;
+/// steer 返回 session/steer-unavailable(引擎无轮内注入点,诚实引导);操作不存在项返回 queue-item-not-found。
+///
+public sealed class DshUpdateQueueTests {
+ private static JsonElement QueueArgs(string sessionId, string itemId, string kind, string? text = null) =>
+ text is null
+ ? JsonSerializer.SerializeToElement(new {
+ sessionId, itemId,
+ action = new { kind },
+ })
+ : JsonSerializer.SerializeToElement(new {
+ sessionId, itemId,
+ action = new { kind, content = new[] { new { type = "text", text } } },
+ });
+
+ [Fact]
+ public async Task UpdateQueue_Remove_DeletesQueuedItem() {
+ var fs = new InMemoryFileSystem();
+ var services = new DshHostServices(fs);
+ await using var transcripts = new State.TranscriptService(fs, "/test/sessions");
+ services.Sessions.AttachTranscripts(transcripts);
+ var hold = new TaskCompletionSource();
+ await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) };
+ services.AttachEngine(engine);
+ var dispatcher = new DshRpcDispatcher(services);
+ try {
+ var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None);
+ var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId;
+ // 第一条占住轮(引擎 busy — TurnGate 挂起 StreamAsync 不返回)
+ var first = await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"));
+ first.Ok.Should().BeTrue(first.Error?.Message);
+ // 第二/三条排队
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第二问", queue: true))).Ok.Should().BeTrue();
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第三问", queue: true))).Ok.Should().BeTrue();
+ var items = dispatcher.ListQueue(sessionId);
+ items.Should().HaveCount(2);
+ var victim = items[0].ItemId;
+
+ var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, victim, "remove"));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ dispatcher.ListQueue(sessionId).Should().ContainSingle().Which.Text.Should().Be("第三问");
+ } finally { hold.TrySetCanceled(); }
+ }
+
+ [Fact]
+ public async Task UpdateQueue_Edit_RewritesQueuedText() {
+ var fs = new InMemoryFileSystem();
+ var services = new DshHostServices(fs);
+ await using var transcripts = new State.TranscriptService(fs, "/test/sessions");
+ services.Sessions.AttachTranscripts(transcripts);
+ var hold = new TaskCompletionSource();
+ await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) };
+ services.AttachEngine(engine);
+ var dispatcher = new DshRpcDispatcher(services);
+ try {
+ var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None);
+ var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId;
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue();
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "旧文本", queue: true))).Ok.Should().BeTrue();
+ var itemId = dispatcher.ListQueue(sessionId).Single().ItemId;
+
+ var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, itemId, "edit", "新文本"));
+
+ result.Ok.Should().BeTrue(result.Error?.Message);
+ dispatcher.ListQueue(sessionId).Single().Text.Should().Be("新文本");
+ } finally { hold.TrySetCanceled(); }
+ }
+
+ [Fact]
+ public async Task UpdateQueue_Steer_ReturnsStableUnavailable() {
+ var fs = new InMemoryFileSystem();
+ var services = new DshHostServices(fs);
+ await using var transcripts = new State.TranscriptService(fs, "/test/sessions");
+ services.Sessions.AttachTranscripts(transcripts);
+ var hold = new TaskCompletionSource();
+ await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) };
+ services.AttachEngine(engine);
+ var dispatcher = new DshRpcDispatcher(services);
+ try {
+ var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None);
+ var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId;
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue();
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "排队问", queue: true))).Ok.Should().BeTrue();
+ var itemId = dispatcher.ListQueue(sessionId).Single().ItemId;
+
+ var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, itemId, "steer"));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("session/steer-unavailable");
+ result.Error?.Message.Should().Contain("排队", "引导用户该消息将在下一轮执行");
+ } finally { hold.TrySetCanceled(); }
+ }
+
+ [Fact]
+ public async Task UpdateQueue_UnknownItem_ReturnsNotFound() {
+ var fs = new InMemoryFileSystem();
+ var services = new DshHostServices(fs);
+ await using var transcripts = new State.TranscriptService(fs, "/test/sessions");
+ services.Sessions.AttachTranscripts(transcripts);
+ var hold = new TaskCompletionSource();
+ await using var engine = new PlaceholderChatSession { TurnGate = ct => hold.Task.WaitAsync(ct) };
+ services.AttachEngine(engine);
+ var dispatcher = new DshRpcDispatcher(services);
+ try {
+ var created = await services.Sessions.CreateAsync(null, "/test/proj", "deepseek", "deepseek-v4-pro", CancellationToken.None);
+ var sessionId = (created ?? throw new Xunit.Sdk.XunitException("session/create 失败")).SessionId;
+ (await dispatcher.HandleAsync("session/prompt", PromptArgs(sessionId, "第一问"))).Ok.Should().BeTrue();
+
+ var result = await dispatcher.HandleAsync("session/updateQueue", QueueArgs(sessionId, "no-such-item", "remove"));
+
+ result.Ok.Should().BeFalse();
+ result.Error?.Code.Should().Be("session/queue-item-not-found");
+ } finally { hold.TrySetCanceled(); }
+ }
+
+ private static JsonElement PromptArgs(string sessionId, string text, bool queue = false) =>
+ JsonSerializer.SerializeToElement(new {
+ request = new {
+ requestId = Guid.NewGuid().ToString("N"),
+ sessionId,
+ mode = queue ? "queue" : "send",
+ content = new[] { new { type = "text", text } },
+ },
+ });
+}
From 2366342fb5d1f37bad27c29298664d9554f0738f Mon Sep 17 00:00:00 2001
From: liuqihong <540762622@qq.com>
Date: Wed, 21 Oct 2026 21:25:37 +0800
Subject: [PATCH 4/4] =?UTF-8?q?fix:=20E2E=20ChatCmd=20Misc=20=E9=80=80?=
=?UTF-8?q?=E5=87=BA=E7=AB=9E=E6=80=81=E8=AF=AF=E6=8A=A5=20|=20=E6=A0=B9?=
=?UTF-8?q?=E5=9B=A0:=20CLI=20=E6=AF=8F=E8=BD=AE=E9=80=80=E5=87=BA?=
=?UTF-8?q?=E6=97=B6=20Host=20dispose=EF=BC=88AsyncLock=20=E9=87=8A?=
=?UTF-8?q?=E6=94=BE=EF=BC=89=E8=80=8C=20McpPluginBootstrap=20=E5=90=8E?=
=?UTF-8?q?=E5=8F=B0=E5=88=B7=E6=96=B0=E4=BB=BB=E5=8A=A1=E6=99=9A=E5=88=B0?=
=?UTF-8?q?=EF=BC=8CObjectDisposedException=20=E8=A2=AB=E8=AE=B0=20LogErro?=
=?UTF-8?q?r=20=E8=A7=A6=E5=8F=91=20NoErrors=20=E6=96=AD=E8=A8=80=EF=BC=9B?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=BC=95=E6=93=8E=E5=85=B3=E9=97=AD?=
=?UTF-8?q?=E6=9C=9F=E8=89=AF=E6=80=A7=E7=AB=9E=E6=80=81=E9=99=8D=E7=BA=A7?=
=?UTF-8?q?=20Debug=EF=BC=88ubuntu=20=E6=97=B6=E5=BA=8F=E5=A4=8D=E7=8E=B0?=
=?UTF-8?q?=EF=BC=8C=E4=B8=8E=20P2=20=E6=94=B9=E5=8A=A8=E6=97=A0=E5=85=B3?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
modules/apps/engine/app/modules/McpInitModule.cs | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/modules/apps/engine/app/modules/McpInitModule.cs b/modules/apps/engine/app/modules/McpInitModule.cs
index e42f09534..36635e2eb 100644
--- a/modules/apps/engine/app/modules/McpInitModule.cs
+++ b/modules/apps/engine/app/modules/McpInitModule.cs
@@ -129,6 +129,10 @@ private static async Task RefreshKernelPluginsAsync(IServiceProvider services, I
var totalCount = plugins.Sum(p => p.Functions.Count());
logger?.LogDebug("[MCP] kernel.Plugins 已刷新,{GroupCount} 组 {Count} 个工具", plugins.Count, totalCount);
+ } catch (ObjectDisposedException ex) {
+ // 引擎关闭期良性竞态:Host 已 dispose(AsyncLock 释放)而后台刷新任务晚到 — 非故障,
+ // 降级 Debug 避免 E2E NoErrors 断言误报(CLI 每轮退出与 McpPluginBootstrap 后台加载的时序差)
+ logger?.LogDebug(ex, "[MCP] 刷新 kernel.Plugins 跳过:引擎已关闭");
} catch (Exception ex) {
logger?.LogError(ex, "[MCP] 刷新 kernel.Plugins 失败");
}