From 7946994c3106c053984e7d11eae68bd29774c007 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 06:48:05 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20M8-8=20=E5=90=8E=E5=8F=B0=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=B8=B8=E9=A9=BB=E9=9D=A2=E6=9D=BF=E5=85=A8=E9=93=BE?= =?UTF-8?q?=EF=BC=88=E5=BC=95=E6=93=8E+TUI=20=E5=8F=8C=E4=BE=A7=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E5=AD=90=E4=BB=A3=E7=90=86+=E4=B8=BB=E6=94=B6?= =?UTF-8?q?=E7=BC=96=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20BackgroundTaskEv?= =?UTF-8?q?entPump=20=E8=AE=A2=E9=98=85=20ForkCompleted/TeammateCompleted?= =?UTF-8?q?=20=E7=9B=B4=E6=8E=A8=20consumer=E2=80=94=E2=80=94=E7=BB=95?= =?UTF-8?q?=E5=BC=80=E5=9B=9E=E5=90=88=E4=BD=9C=E7=94=A8=E5=9F=9F=20SubAge?= =?UTF-8?q?ntEventChannel=20=E7=9A=84=20idle=20=E4=B8=A2=E5=BC=83=20gap(?= =?UTF-8?q?=E5=85=A8=E4=BB=93=E9=9B=B6=E8=AE=A2=E9=98=85=E8=80=85=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E6=88=90=E6=8E=A5=E5=85=A5=E6=9D=A0=E6=9D=86);=20?= =?UTF-8?q?=E5=BF=AB=E7=85=A7=E6=8C=89=E5=AE=9E=E9=99=85=20State=20?= =?UTF-8?q?=E5=8F=91=E8=80=8C=E9=9D=9E=E5=85=A8=E5=8F=91=20running(GetActi?= =?UTF-8?q?veForksList=20=E5=90=AB=E7=BB=88=E6=80=81=E6=9D=A1=E7=9B=AE,?= =?UTF-8?q?=E9=87=8D=E5=8F=91=20running=20=E4=BC=9A=E6=8A=8A=20TUI=20?= =?UTF-8?q?=E5=B7=B2=E5=AE=9A=E6=A0=BC=E7=BB=88=E6=80=81=E6=8B=89=E5=9B=9E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E5=80=92=E9=80=80);=20background=5F?= =?UTF-8?q?task=20=E5=8D=8F=E8=AE=AE=E5=9B=9B=E5=8F=82=20upsert=20?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E5=8F=8C=E9=95=9C=E5=83=8F;=20TUI=20Backgrou?= =?UTF-8?q?ndTaskPanel=20=E7=8B=AC=E7=AB=8B=E5=8D=8F=E8=B0=83=E5=99=A8(?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E9=9D=A2=E6=9D=BF=E4=B8=8A=E6=96=B9=E6=B8=B2?= =?UTF-8?q?=E6=9F=93,=E7=A9=BA=E9=9D=A2=E6=9D=BF=E9=9B=B6=E9=AB=98?= =?UTF-8?q?=E5=BA=A6,=E4=B8=8A=E9=99=9010=E7=AA=97=E5=8F=A3+overflow=20?= =?UTF-8?q?=E6=8F=90=E7=A4=BA,=E4=B8=89=E6=80=81=E8=89=B2=20running=20Text?= =?UTF-8?q?/completed=20Success/failed=20Error);=20BridgeServeLoop=20?= =?UTF-8?q?=E5=8F=AF=E9=80=89=E5=8F=82=E6=95=B0=E6=8E=A5=E7=BA=BF+?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=86=85=20EngineTurnClient=20Consumer=20?= =?UTF-8?q?=E5=87=BA=E5=8F=A3+=E5=9B=9E=E5=90=88=E7=BB=88=E6=80=81=20final?= =?UTF-8?q?ly=20=E5=BF=AB=E7=85=A7(=E6=97=B6=E5=BA=8F=E5=AF=B9=E9=BD=90=20?= =?UTF-8?q?bridge:=E6=99=9A=E4=BA=8E=20turn=5Fcompleted);=20=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E9=A1=BA=E5=BA=8F=E4=BF=AE=E6=AD=A3=3D=E5=85=88?= =?UTF-8?q?=E5=BB=BA=E6=B3=B5=E5=90=8E=E6=9E=84=E9=80=A0=20client=20?= =?UTF-8?q?=E5=86=8D=20Bind(client.Consumer=20=E6=9E=84=E9=80=A0=E5=90=8E?= =?UTF-8?q?=E6=89=8D=E5=AD=98=E5=9C=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/IEventConsumer.cs | 4 + .../core/commands/core/BridgeServeCommand.cs | 16 +- .../cli/core/commands/core/BridgeServeLoop.cs | 26 +- .../adapters/BackgroundTaskEventPump.cs | 138 +++++++++++ .../engine/adapters/BridgeEventConsumer.cs | 6 + modules/apps/engine/bridge/BridgeProtocol.cs | 4 + .../apps/NativeTui.Host/EngineSession.cs | 29 ++- .../apps/NativeTui.Host/EngineTurnClient.cs | 17 +- .../apps/NativeTui.Host/GlobalUsings.cs | 1 + .../NativeTui.Host/InProcessEventConsumer.cs | 9 +- .../src/NativeTui.Core/bridge/BridgeEvent.cs | 7 +- .../NativeTui.Core/bridge/ReplBridgeDriver.cs | 7 +- .../repl/BackgroundTaskPanel.cs | 95 ++++++++ .../src/NativeTui.Core/repl/ReplView.cs | 26 +- .../InProcessEventConsumerTests.cs | 17 ++ .../bridge/ReplBridgeDriverTests.cs | 60 ++++- .../repl/BackgroundTaskPanelTests.cs | 226 ++++++++++++++++++ test/unit/host.tests/GlobalUsings.cs | 1 + .../bridge/BackgroundTaskEventPumpTests.cs | 211 ++++++++++++++++ .../bridge/BridgeEventConsumerTests.cs | 15 ++ .../bridge/BridgeJsonContractTests.cs | 12 + 21 files changed, 904 insertions(+), 23 deletions(-) create mode 100644 modules/apps/engine/adapters/BackgroundTaskEventPump.cs create mode 100644 native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs create mode 100644 native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs create mode 100644 test/unit/host.tests/bridge/BackgroundTaskEventPumpTests.cs diff --git a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs index 35b87dbea..bbe73959d 100644 --- a/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs +++ b/modules/abstractions/abstractions/abs_composition/presentation/IEventConsumer.cs @@ -83,6 +83,10 @@ void OnSubagentFinished(string subagentId, bool isError, int tokenTotal) { } /// 剩余百分比 0-100(引擎侧 ICompactService.CalculateWarningState 权威算法; /// 阈值标志不进接口 — TUI 侧只显示百分比+文案,UI 自己判颜色) void OnContextUsage(int promptTokens, int contextWindowTokens, int percentLeft) { } + + /// 后台任务状态更新(M8-8 — 后台 fork/teammate 代理生命周期,TUI 常驻任务面板显示)。 + /// kind: fork|teammate;state: running|completed|failed|cancelled;upsert 语义按 (kind,taskId)。 + void OnBackgroundTask(string kind, string taskId, string description, string state) { } } /// diff --git a/modules/apps/cli/core/commands/core/BridgeServeCommand.cs b/modules/apps/cli/core/commands/core/BridgeServeCommand.cs index c171610bd..e77ddc6ff 100644 --- a/modules/apps/cli/core/commands/core/BridgeServeCommand.cs +++ b/modules/apps/cli/core/commands/core/BridgeServeCommand.cs @@ -52,6 +52,10 @@ private static async Task ExecuteBridgeAsync(string? vendor, string? model, var fastMode = engine.Services.GetService(); WriteEvent(stdout, BuildSessionInfo(permissionChecker, fastMode, options)); + // M8-8 后台任务事件泵 — 订阅 ForkCompleted/TeammateCompleted 直推 consumer(绕开回合作用域通道), + // 每回合终态后全量快照 upsert;两查询面均可选(引擎未注册时泵为 null,零行为变化) + var backgroundPump = CreateBackgroundTaskPump(engine.Services); + var mainAgent = EngineAgentFactory.CreateMainAgent(engine.Services, new Session().ObjectId); SessionController? controller = null; @@ -61,12 +65,22 @@ private static async Task ExecuteBridgeAsync(string? vendor, string? model, engine.ChatService, consumer, new TurnDiffService(), engine.SessionId, engine.Services, mainAgent: mainAgent); return RunTurnAsync(controller, input, turnCt); - }, ct, BuildPermissionModeApplier(permissionChecker)).ConfigureAwait(false); + }, ct, BuildPermissionModeApplier(permissionChecker), backgroundPump).ConfigureAwait(false); } finally { Console.SetOut(originalOut); } } + /// 构造后台任务事件泵(M8-8)— 解析 fork/teammate 查询面(均引擎单例注册,可选), + /// 任一可用即建泵;两者皆缺返回 null(协议无 background_task 事件流,TUI 面板空置)。 + private static BackgroundTaskEventPump? CreateBackgroundTaskPump(IServiceProvider services) { + var forkManager = services.GetService(); + var teammateExecutor = services.GetService(); + return forkManager is not null || teammateExecutor is not null + ? new BackgroundTaskEventPump(forkManager, teammateExecutor) + : null; + } + /// 解析引擎权限检查器(等模式/安全闸初始化完成;未注册返回 null — 协议降级为仅展示默认值)。 private static async Task ResolvePermissionCheckerAsync(IServiceProvider services) { if (services.GetService() is not PermissionChecker checker) diff --git a/modules/apps/cli/core/commands/core/BridgeServeLoop.cs b/modules/apps/cli/core/commands/core/BridgeServeLoop.cs index 28d0319e9..018ab86d2 100644 --- a/modules/apps/cli/core/commands/core/BridgeServeLoop.cs +++ b/modules/apps/cli/core/commands/core/BridgeServeLoop.cs @@ -31,16 +31,23 @@ internal static class BridgeServeLoop { /// 支持的退出码 — 正常退出(quit/Eof/外部取消均收尾后返回 0)。 private const int ExitOk = 0; - /// 运行协议主循环直到 quit/Eof/外部取消(applyPermissionMode 为 null 时 set_permission_mode 回 error 诱导提示)。 + /// 运行协议主循环直到 quit/Eof/外部取消(applyPermissionMode 为 null 时 set_permission_mode 回 error 诱导提示)。 + /// backgroundPump 可选 — 组装层(BridgeServeCommand)注入时:consumer 构造后 Bind+Start(订阅后台 fork/teammate + /// 完成事件直推 background_task 行),每回合终态后 PublishSnapshotAsync(TUI 面板与引擎状态对齐),循环退出前 StopAsync。 public static async Task RunAsync( TextReader stdin, TextWriter stdout, TextWriter stderr, TurnExecutor executeTurn, CancellationToken ct, - PermissionModeApplier? applyPermissionMode = null) { + PermissionModeApplier? applyPermissionMode = null, + BackgroundTaskEventPump? backgroundPump = null) { var synced = TextWriter.Synchronized(stdout); var consumer = new BridgeEventConsumer(synced); + if (backgroundPump is not null) { + backgroundPump.Bind(consumer); + await backgroundPump.StartAsync().ConfigureAwait(false); + } CancellationTokenSource? turnCts = null; Task? turnTask = null; var exit = false; @@ -56,7 +63,7 @@ public static async Task RunAsync( break; default: if (request is not null) - (exit, turnCts, turnTask) = DispatchRequest(request, synced, consumer, executeTurn, applyPermissionMode, turnCts, turnTask); + (exit, turnCts, turnTask) = DispatchRequest(request, synced, consumer, executeTurn, applyPermissionMode, backgroundPump, turnCts, turnTask); break; } } @@ -65,6 +72,8 @@ public static async Task RunAsync( if (turnTask is not null) await turnTask.ConfigureAwait(false); turnCts?.Dispose(); + if (backgroundPump is not null) + await backgroundPump.StopAsync().ConfigureAwait(false); return ExitOk; } @@ -75,6 +84,7 @@ private static (bool Exit, CancellationTokenSource? TurnCts, Task? TurnTask) Dis BridgeEventConsumer consumer, TurnExecutor executeTurn, PermissionModeApplier? applyPermissionMode, + BackgroundTaskEventPump? backgroundPump, CancellationTokenSource? turnCts, Task? turnTask) { switch (request) { @@ -87,7 +97,7 @@ private static (bool Exit, CancellationTokenSource? TurnCts, Task? TurnTask) Dis turnCts?.Dispose(); var newCts = new CancellationTokenSource(); - var newTask = RunTurnAsync(stdout, consumer, executeTurn, text, newCts.Token); + var newTask = RunTurnAsync(stdout, consumer, executeTurn, backgroundPump, text, newCts.Token); return (false, newCts, newTask); case Cli.Bridge.BridgeRequest.Interrupt: turnCts?.Cancel(); @@ -117,11 +127,14 @@ private static void ApplyPermissionMode(TextWriter stdout, PermissionModeApplier $"权限模式 '{mode}' 无法应用(未知值或引擎未提供权限系统)。合法值:{ValidPermissionModes}。bypass 受 settings.json 的 disableBypassPermissionsMode 安全闸管控,被拦截时保持原模式。")); } - /// 执行一个回合:turn_started → executor(事件实时推送)→ completed/interrupted/error。 + /// 执行一个回合:turn_started → executor(事件实时推送)→ completed/interrupted/error + /// → 后台任务快照全量 upsert(回合结束边界 — idle 期 TUI 面板与引擎后台任务状态对齐; + /// 泵内部吞查询异常,快照失败不杀回合收尾)。 private static async Task RunTurnAsync( TextWriter stdout, BridgeEventConsumer consumer, TurnExecutor executeTurn, + BackgroundTaskEventPump? backgroundPump, string input, CancellationToken ct) { WriteEvent(stdout, new Cli.Bridge.BridgeEvent.TurnStarted()); @@ -133,6 +146,9 @@ private static async Task RunTurnAsync( WriteEvent(stdout, new Cli.Bridge.BridgeEvent.TurnInterrupted()); } catch (Exception ex) { WriteEvent(stdout, new Cli.Bridge.BridgeEvent.Error($"回合执行失败:{ex.Message}")); + } finally { + if (backgroundPump is not null) + await backgroundPump.PublishSnapshotAsync(CancellationToken.None).ConfigureAwait(false); } } diff --git a/modules/apps/engine/adapters/BackgroundTaskEventPump.cs b/modules/apps/engine/adapters/BackgroundTaskEventPump.cs new file mode 100644 index 000000000..e0bd6f3ab --- /dev/null +++ b/modules/apps/engine/adapters/BackgroundTaskEventPump.cs @@ -0,0 +1,138 @@ +namespace JoinCode.Adapters; + +/// +/// 后台任务事件泵(M8-8)— 绕开回合作用域的 SubAgentEventChannel,把后台 fork/teammate 生命周期 +/// 直推 (TUI 常驻任务面板事件源): +/// 结构性动机:SubAgentEventChannel 是 AsyncLocal 回合作用域,idle 期到达的后台终态事件被死通道 +/// 静默丢弃 — 泵改为进程级订阅 与 +/// (两事件源全仓此前零订阅者,现成接入杠杆), +/// 事件到达即推 consumer,与回合边界解耦。 +/// 两查询面均为可选注入(引擎未注册时对应路径静默跳过,对齐 AgentForkMiddleware 可选依赖形态); +/// 延迟绑定推送出口(消费方实例晚于泵构造 — BridgeServeLoop 内建 consumer 后绑定)。 +/// 线程安全:事件从 Actor 后台线程到达,consumer 推送安全由实现方自保(BridgeEventConsumer 走 +/// TextWriter.Synchronized、InProcessEventConsumer 入队 FIFO),泵侧仅以 volatile 引用交接,不加锁。 +/// +public sealed class BackgroundTaskEventPump : IAsyncDisposable { + private readonly IForkSubAgentManager? _forkManager; + private readonly IInProcessTeammateTaskExecutor? _teammateExecutor; + private readonly ILogger? _logger; + private volatile IEventConsumer? _consumer; + private int _subscribed; + + /// 构造函数 — 注入 fork/teammate 查询与完成事件面(均可选)与可选日志。 + /// fork 管理器(ForkCompleted 终态事件 + GetActiveForksAsync 快照;null 时 fork 路径静默跳过) + /// teammate 执行器(TeammateCompleted 终态事件 + GetActiveTeammateSnapshotsAsync 快照;null 时 teammate 路径静默跳过) + /// 可选日志(快照查询异常降级记录) + public BackgroundTaskEventPump( + IForkSubAgentManager? forkManager, + IInProcessTeammateTaskExecutor? teammateExecutor, + ILogger? logger = null) { + _forkManager = forkManager; + _teammateExecutor = teammateExecutor; + _logger = logger; + } + + /// 绑定推送出口(组装层在 consumer 就绪后调用;重绑覆盖 — consumer 全程单实例场景)。 + /// 事件消费出口(OnBackgroundTask 四参 upsert) + public void Bind(IEventConsumer consumer) { + ArgumentNullException.ThrowIfNull(consumer); + _consumer = consumer; + } + + /// 订阅两完成事件面(幂等 — 重复调用零副作用;无异步等待,订阅即生效)。 + public Task StartAsync() { + if (Interlocked.Exchange(ref _subscribed, 1) != 0) + return Task.CompletedTask; + + if (_forkManager is not null) + _forkManager.ForkCompleted += OnForkCompleted; + if (_teammateExecutor is not null) + _teammateExecutor.TeammateCompleted += OnTeammateCompleted; + return Task.CompletedTask; + } + + /// 全量快照 upsert(回合结束边界调用)— fork 按快照实际 State 推(running 补发 + 终态幂等重发, + /// 避免把 TUI 已定格的终态条目拉回 running 的倒退);teammate 快照为活跃集合,全发 running。 + /// 单查询面异常降级日志不中断(Actor 可能已释放,监控路径不得杀回合收尾)。 + /// 取消令牌 + public async Task PublishSnapshotAsync(CancellationToken ct = default) { + var consumer = _consumer; + if (consumer is null) + return; + + if (_forkManager is not null) { + try { + var forks = await _forkManager.GetActiveForksAsync(ct).ConfigureAwait(false); + foreach (var fork in forks) + consumer.OnBackgroundTask(BackgroundTaskKinds.Fork, fork.ForkId, string.Empty, MapForkState(fork.State)); + } catch (Exception ex) { + _logger?.LogWarning(ex, "后台任务泵 fork 快照查询失败,跳过本批"); + } + } + + if (_teammateExecutor is not null) { + try { + var teammates = await _teammateExecutor.GetActiveTeammateSnapshotsAsync(ct).ConfigureAwait(false); + foreach (var teammate in teammates) + consumer.OnBackgroundTask(BackgroundTaskKinds.Teammate, teammate.TeammateId, teammate.Task, BackgroundTaskStates.Running); + } catch (Exception ex) { + _logger?.LogWarning(ex, "后台任务泵 teammate 快照查询失败,跳过本批"); + } + } + } + + /// 退订两完成事件面(幂等 — 未订阅/重复退订零副作用;无超时无等待,退订即完成)。 + public Task StopAsync() { + if (Interlocked.Exchange(ref _subscribed, 0) != 0) { + if (_forkManager is not null) + _forkManager.ForkCompleted -= OnForkCompleted; + if (_teammateExecutor is not null) + _teammateExecutor.TeammateCompleted -= OnTeammateCompleted; + } + return Task.CompletedTask; + } + + /// 释放 — 等价 StopAsync 退订(无资源需异步清理)。 + public ValueTask DisposeAsync() => new(StopAsync()); + + /// ForkCompleted → OnBackgroundTask("fork", forkId, taskDescription, 终态)。 + /// Bind 前到达的事件丢弃(组装顺序保证泵先 Bind 后 Start,仅测试直接触发可能越过)。 + private void OnForkCompleted(object? sender, ForkCompletedEventArgs e) => + _consumer?.OnBackgroundTask(BackgroundTaskKinds.Fork, e.ForkId, e.TaskDescription, MapForkState(e.State)); + + /// TeammateCompleted → OnBackgroundTask("teammate", teammateId, task, 终态)。 + /// 事件参数只有 IsSuccess 二态 — 被终止/失败统一映射 failed(无 cancelled 信号源,见泵 XML 注释)。 + private void OnTeammateCompleted(object? sender, TeammateCompletedEventArgs e) => + _consumer?.OnBackgroundTask( + BackgroundTaskKinds.Teammate, e.TeammateId, e.Task, + e.IsSuccess ? BackgroundTaskStates.Completed : BackgroundTaskStates.Failed); + + /// ForkState → 协议四值:[EnumValue] 唯一数据源取值,merged 折叠 completed + /// (合并即成功终态 — MergeFork 不发完成事件,快照路径亦过滤 merged,此处仅防御性折叠)。 + private static string MapForkState(ForkState state) => + state.ToValue() == "merged" ? BackgroundTaskStates.Completed : state.ToValue(); +} + +/// 后台任务种类(M8-8 — 协议 kind 字段值;有限集合字符串常量,后续枚举化随面板扩展收编)。 +public static class BackgroundTaskKinds { + /// 后台 fork 子代理(AgentForkMiddleware 回退路径 — 继承父上下文分叉)。 + public const string Fork = "fork"; + + /// 后台 teammate 子代理(AgentForkMiddleware 优先路径 — ContinuousMode 消息驱动)。 + public const string Teammate = "teammate"; +} + +/// 后台任务状态(M8-8 — 协议 state 字段值;与 native_tui 侧约定四值)。 +public static class BackgroundTaskStates { + /// 运行中。 + public const string Running = "running"; + + /// 成功终态。 + public const string Completed = "completed"; + + /// 失败终态。 + public const string Failed = "failed"; + + /// 取消终态。 + public const string Cancelled = "cancelled"; +} diff --git a/modules/apps/engine/adapters/BridgeEventConsumer.cs b/modules/apps/engine/adapters/BridgeEventConsumer.cs index 23d721dc6..a6a12afae 100644 --- a/modules/apps/engine/adapters/BridgeEventConsumer.cs +++ b/modules/apps/engine/adapters/BridgeEventConsumer.cs @@ -98,6 +98,12 @@ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percen WriteEvent(new Cli.Bridge.BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft)); } + /// 后台任务状态更新 → background_task(M8-8 — 事件泵直推;显式实现:DIM 默认实现经类类型调用不可见, + /// 缺显式实现时泵经 IEventConsumer 调用会静默落入接口空实现 — DSG041 批3 先例)。 + public void OnBackgroundTask(string kind, string taskId, string description, string state) { + WriteEvent(new Cli.Bridge.BridgeEvent.BackgroundTask(kind, taskId, description, state)); + } + /// 思考结束结算(进入产出或回合结束时调用,幂等)。 private void EndThinkingIfActive() { if (_thinkingStartMs is not long start) diff --git a/modules/apps/engine/bridge/BridgeProtocol.cs b/modules/apps/engine/bridge/BridgeProtocol.cs index d8738446a..195237088 100644 --- a/modules/apps/engine/bridge/BridgeProtocol.cs +++ b/modules/apps/engine/bridge/BridgeProtocol.cs @@ -45,6 +45,7 @@ public sealed record Quit() : BridgeRequest; [JsonDerivedType(typeof(BridgeEvent.SubagentToolUse), "subagent_tool_use")] [JsonDerivedType(typeof(BridgeEvent.SubagentFinished), "subagent_finished")] [JsonDerivedType(typeof(BridgeEvent.ContextUsage), "context_usage")] +[JsonDerivedType(typeof(BridgeEvent.BackgroundTask), "background_task")] [JsonDerivedType(typeof(BridgeEvent.TurnCompleted), "turn_completed")] [JsonDerivedType(typeof(BridgeEvent.TurnInterrupted), "turn_interrupted")] [JsonDerivedType(typeof(BridgeEvent.Error), "error")] @@ -140,6 +141,9 @@ public sealed record SubagentFinished(string SubagentId, bool IsError, int Token /// public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent; + /// 后台任务状态更新(M8-8 — upsert 语义:TUI 按 (kind,taskId) 更新/插入面板条目;终态由 state 自含)。 + public sealed record BackgroundTask(string Kind, string TaskId, string Description, string State) : BridgeEvent; + /// 完整助手消息(追加滚动区)。 public sealed record AssistantMessage(string Text) : BridgeEvent; diff --git a/native_tui/apps/NativeTui.Host/EngineSession.cs b/native_tui/apps/NativeTui.Host/EngineSession.cs index 9533d2672..8879aaca3 100644 --- a/native_tui/apps/NativeTui.Host/EngineSession.cs +++ b/native_tui/apps/NativeTui.Host/EngineSession.cs @@ -14,14 +14,16 @@ public sealed class EngineSession : IAsyncDisposable { private readonly PermissionChecker? _checker; private readonly IFastModeService? _fastMode; private readonly TuiCommandCallbacks _commandCallbacks; + private readonly BackgroundTaskEventPump? _backgroundPump; - private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode, TuiCommandCallbacks commandCallbacks) { + private EngineSession(EngineSessionFactory.Result engine, EngineTurnClient client, TuiPermissionBridge permissionBridge, PermissionChecker? checker, IFastModeService? fastMode, TuiCommandCallbacks commandCallbacks, BackgroundTaskEventPump? backgroundPump) { _engine = engine; _client = client; _permissionBridge = permissionBridge; _checker = checker; _fastMode = fastMode; _commandCallbacks = commandCallbacks; + _backgroundPump = backgroundPump; } /// 回合客户端 — 主循环消费面(Submit/Interrupt/SetPermissionMode/DrainEvents)。 @@ -64,6 +66,15 @@ public static async Task CreateAsync(CancellationToken cancellati // Program 在 shell 构造后 Bind:/clear → 清滚动区、/exit → 主循环结束) var commandCallbacks = new TuiCommandCallbacks(); + // M8-8 后台任务事件泵 — 订阅 ForkCompleted/TeammateCompleted 直推 consumer + // (绕开回合作用域 SubAgentEventChannel 的 idle 丢弃 gap);两面任一可解析即建泵 + var forkManager = engine.Services.GetService(); + var teammateExecutor = engine.Services.GetService(); + BackgroundTaskEventPump? backgroundPump = null; + if (forkManager is not null || teammateExecutor is not null) { + backgroundPump = new BackgroundTaskEventPump(forkManager, teammateExecutor); + } + // 首次 submit 时绑定回合驱动器 — consumer 即进程内事件出口(client 构造,全程同一实例,对齐 BridgeServeCommand 先例) SessionController? controller = null; var client = new EngineTurnClient( @@ -78,9 +89,15 @@ public static async Task CreateAsync(CancellationToken cancellati mainAgent: mainAgent); return RunTurnAsync(controller, input, turnCt); }, - BuildPermissionModeApplier(checker)); + BuildPermissionModeApplier(checker), + backgroundPump); + + if (backgroundPump is not null) { + backgroundPump.Bind(client.Consumer); + await backgroundPump.StartAsync().ConfigureAwait(false); + } - var session = new EngineSession(engine, client, permissionBridge, checker, fastMode, commandCallbacks); + var session = new EngineSession(engine, client, permissionBridge, checker, fastMode, commandCallbacks, backgroundPump); client.Enqueue(BuildSessionInfoEvent(checker, fastMode, engine.Config)); return session; } catch { @@ -182,8 +199,12 @@ internal static async Task DispatchSlashCommandAsync( return new InvalidOperationException(result.ErrorMessage ?? "回合执行失败(未知错误)"); } - /// 释放 — 取消在途回合(client.Dispose 快速返回)+ 异步释放引擎 Host。 + /// 释放 — 后台任务泵退订(先于引擎 Host — Actor 释放后事件源消失)+ 取消在途回合(client.Dispose 快速返回)+ 异步释放引擎 Host。 public async ValueTask DisposeAsync() { + if (_backgroundPump is not null) { + await _backgroundPump.DisposeAsync().ConfigureAwait(false); + } + _client.Dispose(); await _engine.Host.DisposeAsync().ConfigureAwait(false); } diff --git a/native_tui/apps/NativeTui.Host/EngineTurnClient.cs b/native_tui/apps/NativeTui.Host/EngineTurnClient.cs index 00ab67d1d..1d9cbe003 100644 --- a/native_tui/apps/NativeTui.Host/EngineTurnClient.cs +++ b/native_tui/apps/NativeTui.Host/EngineTurnClient.cs @@ -15,17 +15,24 @@ public sealed class EngineTurnClient : IReplEngine, IDisposable { private readonly InProcessEventConsumer _consumer; private readonly TurnExecutor _executeTurn; private readonly PermissionModeApplier? _applyPermissionMode; + private readonly BackgroundTaskEventPump? _backgroundPump; private readonly Lock _turnLock = new(); private CancellationTokenSource? _turnCts; private Task? _turnTask; - /// 构造函数 — 注入回合执行器(生产侧绑定 SessionController.StreamResponseAsync)与权限模式应用器(可选)。 - public EngineTurnClient(TurnExecutor executeTurn, PermissionModeApplier? applyPermissionMode = null) { + /// 构造函数 — 注入回合执行器(生产侧绑定 SessionController.StreamResponseAsync)、权限模式应用器(可选) + /// 与后台任务事件泵(M8-8 可选 — 回合终态后快照下发,null = 无后台任务事件)。 + public EngineTurnClient(TurnExecutor executeTurn, PermissionModeApplier? applyPermissionMode = null, + BackgroundTaskEventPump? backgroundPump = null) { _consumer = new InProcessEventConsumer(_events); _executeTurn = executeTurn; _applyPermissionMode = applyPermissionMode; + _backgroundPump = backgroundPump; } + /// 进程内事件出口 — 组装层后台任务泵 Bind 用(M8-8;全程单实例)。 + internal IEventConsumer Consumer => _consumer; + /// 回合执行器 — 生产侧绑定 SessionController,测试侧注入假执行器模拟 LLM 流(对齐 BridgeServeLoop.TurnExecutor)。 /// 用户提交文本 /// 回合事件消费器(执行器内触发回调实时推送事件) @@ -95,7 +102,8 @@ public IReadOnlyList DrainEvents() { void IReplEngine.SetPermissionMode(string mode) => SetPermissionMode(mode); /// 执行单个回合 — 终态翻译对齐 BridgeServeCommand.RunTurnAsync:成功 → turn_completed; - /// 取消 → turn_interrupted;异常 → error(异常消息含诱导上下文)。终态事件入队先于回合 Task 完成(无竞态窗口)。 + /// 取消 → turn_interrupted;异常 → error(异常消息含诱导上下文)。终态事件入队先于回合 Task 完成(无竞态窗口)。 + /// M8-8:回合终态后发后台任务快照(background_task 行晚于 turn_completed — 对齐 bridge 路径时序)。 private async Task RunTurnAsync(string input, CancellationToken ct) { try { await _executeTurn(input, _consumer, ct).ConfigureAwait(false); @@ -104,6 +112,9 @@ private async Task RunTurnAsync(string input, CancellationToken ct) { _events.Enqueue(new BridgeEvent.TurnInterrupted()); } catch (Exception ex) { _events.Enqueue(new BridgeEvent.Error($"回合执行失败: {ex.Message}")); + } finally { + if (_backgroundPump is not null) + await _backgroundPump.PublishSnapshotAsync(CancellationToken.None).ConfigureAwait(false); } } diff --git a/native_tui/apps/NativeTui.Host/GlobalUsings.cs b/native_tui/apps/NativeTui.Host/GlobalUsings.cs index cfae9ef2c..48834f00e 100644 --- a/native_tui/apps/NativeTui.Host/GlobalUsings.cs +++ b/native_tui/apps/NativeTui.Host/GlobalUsings.cs @@ -3,6 +3,7 @@ global using System.Text; global using Core.Configuration; global using Core.Permission; +global using Core.Scheduling.Tasks; global using Core.Utils; global using JoinCode.Abstractions.Entity; global using JoinCode.Abstractions.Configuration; diff --git a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs index a5deee01e..84c1c7f91 100644 --- a/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs +++ b/native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs @@ -7,7 +7,8 @@ namespace NativeTui.Host; /// 不经 JSON 序列化。语义与 engine BridgeEventConsumer 完全对齐(唯一差异:WriteEvent 序列化 stdout → 队列写内存对象): /// OnText → token_delta;OnThinking 回合内单次信号 + 墙钟差结算 thinking_ended; /// OnToolStart/OnToolEnd → tool_call/tool_result(摘要截断 200 + error 前缀 + StructuredPatch → DiffHunk 投影); -/// OnDone → assistant_message(完整文本);OnToolProgress/OnLoopDetected/OnTimingSummary 不消费。 +/// OnDone → assistant_message(完整文本);OnBackgroundTask → background_task(M8-8 常驻面板 upsert); +/// OnToolProgress/OnLoopDetected/OnTimingSummary 不消费。 /// public sealed class InProcessEventConsumer : IResettableEventConsumer { /// 工具结果摘要截断上限(对齐 engine BridgeEventConsumer 的 200 字符显示截断)。 @@ -131,6 +132,12 @@ public void OnContextUsage(int promptTokens, int contextWindowTokens, int percen _events.Enqueue(new BridgeEvent.ContextUsage(promptTokens, contextWindowTokens, percentLeft)); } + /// 后台任务状态更新 → BackgroundTask(M8-8 — 四参直传内存事件;(kind,taskId) upsert 常驻面板条目, + /// 终态驻留。接口同步由引擎侧 IEventConsumer DIM 完成 — 签名一致自动成为实现,未就位时孤立方法编译即过)。 + public void OnBackgroundTask(string kind, string taskId, string description, string state) { + _events.Enqueue(new BridgeEvent.BackgroundTask(kind, taskId, description, state)); + } + /// 排空事件队列 — 主循环每 tick 调用,取走全部待处理事件(ConcurrentQueue 多生产单消费安全)。 public IReadOnlyList DrainEvents() { List? drained = null; diff --git a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs index 186bccb4b..821e923f4 100644 --- a/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs +++ b/native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs @@ -4,7 +4,8 @@ namespace NativeTui.Core.Bridge; /// bridge 事件(Engine → TUI,读子进程 stdout)— ADR 0141 事件契约: /// 会话握手(session_info)、权限模式(permission_mode_changed)、 /// 回合生命周期(turn_started/completed/interrupted)、思考(thinking_started/ended)、 -/// 工具(tool_call/tool_result/permission_request)、流式(token_delta)、消息(assistant_message/error)。 +/// 工具(tool_call/tool_result/permission_request)、流式(token_delta)、消息(assistant_message/error)、 +/// 后台任务(background_task — M8-8 常驻面板 upsert)。 /// [JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] [JsonDerivedType(typeof(SessionInfo), "session_info")] @@ -23,6 +24,7 @@ namespace NativeTui.Core.Bridge; [JsonDerivedType(typeof(SubagentToolUse), "subagent_tool_use")] [JsonDerivedType(typeof(SubagentFinished), "subagent_finished")] [JsonDerivedType(typeof(ContextUsage), "context_usage")] +[JsonDerivedType(typeof(BackgroundTask), "background_task")] [JsonDerivedType(typeof(Error), "error")] public abstract record BridgeEvent { @@ -111,6 +113,9 @@ public sealed record SubagentFinished(string SubagentId, bool IsError, int Token /// public sealed record ContextUsage(int PromptTokens, int ContextWindowTokens, int PercentLeft) : BridgeEvent; + /// 后台任务状态更新(M8-8 — upsert 语义:按 (kind,taskId) 更新/插入面板条目;终态由 state 自含)。 + public sealed record BackgroundTask(string Kind, string TaskId, string Description, string State) : BridgeEvent; + /// 错误(含诱导说明)。 public sealed record Error(string Message) : BridgeEvent; } diff --git a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs index a29ebfd6f..53e4eaa8c 100644 --- a/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs +++ b/native_tui/src/NativeTui.Core/bridge/ReplBridgeDriver.cs @@ -8,7 +8,8 @@ namespace NativeTui.Core.Bridge; /// (spinner 恒为末项),token_delta 累加 ResponseLength,错误追加 ErrorMessageItem; /// 工具调用聚合(M8-4):连续同名可聚合调用(Read/Grep/Glob)合并为 CollapsedToolItem /// (首次独立 ToolCallItem,第二次同名替换,后续 Record — 工具结果不断链,其他消息事件/回合边界断链); -/// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态)。 +/// 权限请求(permission_request)经注入钩子转交 shell 挂起对话框(权限模态是 shell 层状态); +/// 后台任务(background_task)路由常驻面板 upsert(M8-8 — (kind,taskId) 更新/插入,终态驻留)。 /// 模拟路径与真实 bridge 路径共用同一套回合视图状态。 /// public sealed class ReplBridgeDriver @@ -168,6 +169,10 @@ public void Apply(BridgeEvent bridgeEvent) case BridgeEvent.ContextUsage(_, _, var percentLeft): ApplyContextUsage(percentLeft); break; + case BridgeEvent.BackgroundTask(var kind, var taskId, var description, var state): + // M8-8 常驻面板 upsert — (kind,taskId) 更新/插入条目,终态驻留不随回合清理 + _view.BackgroundTasks.Upsert(kind, taskId, description, state); + break; case BridgeEvent.Error(var message): EndToolChain(); AddMessage(new ErrorMessageItem(message)); diff --git a/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs new file mode 100644 index 000000000..12e749a0a --- /dev/null +++ b/native_tui/src/NativeTui.Core/repl/BackgroundTaskPanel.cs @@ -0,0 +1,95 @@ +namespace NativeTui.Core.Repl; + +/// +/// 后台任务条目(M8-8)— 常驻面板单行渲染状态投影(不可变 record,Upsert 替换式更新)。 +/// State 直通存储协议原串(running|completed|failed|cancelled — 渲染层映射汉化文案与色, +/// 未知值宽容原样显示;不引入枚举双源 — record 须保留原始串支持协议演进)。 +/// +/// 任务类别(协议值 fork|teammate — 行内原样显示)。 +/// 任务标识(与 Kind 组成复合 upsert 键)。 +/// 任务描述(行正文)。 +/// 任务状态(协议原串 — 渲染层映射,未知值宽容)。 +public sealed record BackgroundTaskEntry(string Kind, string TaskId, string Description, string State); + +/// +/// 后台任务常驻面板(M8-8 TASK064 末项首版)— 对齐基准 CoordinatorAgentStatus.tsx:34-76 首版简化汉化: +/// 输入区上方排队面板同位的只读常驻面板(每任务一行 "● kind: 描述 · 状态"), +/// upsert 语义(按 (kind,taskId) 更新/插入 — SubagentGroupCoordinator 先例的独立协调器形态, +/// 区别:不落滚动区、终态驻留不随回合清理)、上限窗口 min(10, n) + "+N more" 溢出行 +/// (基准全量渲染靠 React 虚拟化扛 300 规模,本项目无虚拟化 — 窗口+提示为对等机制, +/// 对齐 MaxQueuedInputs=10 / SubagentGroupCoordinator.MaxVisible 先例)。 +/// 首版只读显示无停止交互;evictAfter 过期驱逐下批做。 +/// 消费方:ReplBridgeDriver 路由(BackgroundTask 事件)+ ReplView 渲染(排队面板之上)。 +/// +public sealed class BackgroundTaskPanel +{ + /// 最大可见条数(对齐 MaxQueuedInputs=10 / SubagentGroupCoordinator.MaxVisible 先例 — 防 300 规模刷屏)。 + public const int MaxVisible = 10; + + private readonly List _tasks = []; + + /// 任务条目清单(插入序 — 渲染窗口取前 N 条,断言投影)。 + public IReadOnlyList Tasks => _tasks; + + /// 可见条数 = min(10, n)。 + public int VisibleCount => Math.Min(MaxVisible, _tasks.Count); + + /// 窗口外条数("+N more" 溢出行依据)。 + public int HiddenCount => Math.Max(0, _tasks.Count - VisibleCount); + + /// 面板高度 — 空面板 0(对齐 SpinnerItem.Hidden 空态零渲染先例);超窗 +1 行溢出提示。 + public int MeasureHeight() => _tasks.Count == 0 ? 0 : VisibleCount + (HiddenCount > 0 ? 1 : 0); + + /// upsert — 按 (kind,taskId) 复合键查找:存在替换更新(描述/状态),不存在插入(终态驻留 — 首版不驱逐)。 + public void Upsert(string kind, string taskId, string description, string state) + { + var index = _tasks.FindIndex(task => task.Kind == kind && task.TaskId == taskId); + var updated = new BackgroundTaskEntry(kind, taskId, description, state); + if (index < 0) + { + _tasks.Add(updated); + return; + } + + _tasks[index] = updated; + } + + /// 渲染 — 每任务一行 "● kind: 描述 · 状态":● 状态色(running text / completed success / + /// failed、cancelled error)、kind+描述 normal、状态 dim;超窗末行 "+N more" dim;空面板零输出。 + public void Render(Screen screen, Rect frame, Theme theme) + { + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + for (var i = 0; i < VisibleCount; i++) + { + var task = _tasks[i]; + var row = frame.Y + i; + var dotStyle = screen.Styles.GetOrAdd(new TextStyle { Foreground = DotColor(task.State, theme) }); + var col = screen.WriteText(row, frame.X, $"{Figures.BlackCircle} ", dotStyle, frame.Right); + col = screen.WriteText(row, col, $"{task.Kind}: {task.Description}", styleId: 0, rightBound: frame.Right); + screen.WriteText(row, col, $"{Figures.MiddotSeparator}{StatusText(task.State)}", dim, frame.Right); + } + + if (HiddenCount > 0) + { + screen.WriteText(frame.Y + VisibleCount, frame.X, $"+{HiddenCount} more", dim, frame.Right); + } + } + + /// 状态文案汉化(running 运行中 / completed 已完成 / failed 失败 / cancelled 已取消;未知值原样显示 — 协议演进宽容)。 + private static string StatusText(string state) => state switch + { + "running" => "运行中", + "completed" => "已完成", + "failed" => "失败", + "cancelled" => "已取消", + _ => state, + }; + + /// ● 状态色(running theme.Text / completed theme.Success / failed、cancelled theme.Error;未知值同 running)。 + private static Color DotColor(string state, Theme theme) => state switch + { + "completed" => theme.Success, + "failed" or "cancelled" => theme.Error, + _ => theme.Text, + }; +} diff --git a/native_tui/src/NativeTui.Core/repl/ReplView.cs b/native_tui/src/NativeTui.Core/repl/ReplView.cs index ca7e1b61b..ed040326a 100644 --- a/native_tui/src/NativeTui.Core/repl/ReplView.cs +++ b/native_tui/src/NativeTui.Core/repl/ReplView.cs @@ -4,6 +4,7 @@ namespace NativeTui.Core.Repl; /// REPL 整屏视图 — 对齐基准 REPL.tsx bottom slot 布局(自底向上): /// footer 1-2 行(paddingX=2,通知非空时 2 行)+ 建议区 0-6 行(斜杠补全激活时,基准 PromptInputFooterSuggestions) /// + 输入底边线 1 行 + 输入文本 1 行 + 输入顶边线 1 行(基准 PromptInput 上下全宽 ─ 分隔线) +/// + 排队面板 0-N 行(输入框上方,基准 queued)+ 后台任务面板 0-N 行(排队面板之上,M8-8 常驻 — 基准 CoordinatorAgentStatus) /// + 间隔 1 行(输入框 marginTop=1),其余全部给消息滚动区(sticky 钉底)。 /// spinner 行(输入框上方,基准 scrollable 区尾部)由滚动区尾部 SpinnerItem 承载。 /// @@ -44,21 +45,27 @@ public sealed class ReplView /// 回合结束自动发送时才上屏为用户消息)。 public IReadOnlyList QueuedInputs { get; set; } = []; + /// 后台任务常驻面板(M8-8 — 可变实例原地 upsert,Driver 经此引用直接更新;渲染在排队面板之上)。 + public BackgroundTaskPanel BackgroundTasks { get; } = new(); + /// 建议区高度(0 = 未激活)。 private int SuggestionHeight => _suggestionBox.MeasureHeight(Suggestions?.Count ?? 0); /// 排队面板高度(0 = 无排队)。 private int QueueHeight => QueuedInputs.Count; + /// 后台任务面板高度(0 = 无任务 — 空面板零渲染)。 + private int BackgroundTaskHeight => BackgroundTasks.MeasureHeight(); + /// - /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + 排队面板 + 建议区 + footer 高度; + /// 内容总高度(inline 模式建屏用)— 消息全量高度 + 间隔 1 + 输入区 3 + 排队面板 + 后台任务面板 + 建议区 + footer 高度; /// 屏高取此值时消息区恰好全量展示(无滚动窗口),历史自然滚入终端 scrollback。 /// public int MeasureContentHeight(int cols) => - Messages.MeasureContentHeight(cols) + 4 + QueueHeight + SuggestionHeight + _footer.MeasureHeight(Footer); + Messages.MeasureContentHeight(cols) + 4 + QueueHeight + BackgroundTaskHeight + SuggestionHeight + _footer.MeasureHeight(Footer); - /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + 排队面板 + 建议区 + footer 高度)— 退出清理用。 - public int TailHeight => 4 + QueueHeight + SuggestionHeight + _footer.MeasureHeight(Footer); + /// inline 模式尾部动态区行数(间隔 1 + 输入区 3 + 排队面板 + 后台任务面板 + 建议区 + footer 高度)— 退出清理用。 + public int TailHeight => 4 + QueueHeight + BackgroundTaskHeight + SuggestionHeight + _footer.MeasureHeight(Footer); /// /// 输入光标内容坐标(inline 模式帧尾光标位)— 行 = 输入文本行(内容底上方 footer+建议区+2), @@ -71,12 +78,13 @@ public CursorPos InputCursorPos(int contentRows, int cols, int cursorCol) return new CursorPos(Math.Min(cols, PromptInputBox.PromptWidth + displayCol), contentRows - footerHeight - SuggestionHeight - 2); } - /// 渲染整屏(消息区 → 排队面板 → 输入框 → 建议区 → footer)。 + /// 渲染整屏(消息区 → 后台任务面板 → 排队面板 → 输入框 → 建议区 → footer)。 public void Render(Screen screen, Theme theme) { var footerHeight = _footer.MeasureHeight(Footer); var suggestionHeight = SuggestionHeight; var queueHeight = QueueHeight; + var backgroundHeight = BackgroundTaskHeight; var footerFrame = new Rect(2, screen.Rows - footerHeight, Math.Max(0, screen.Cols - 4), footerHeight); _footer.Render(screen, footerFrame, theme, Footer); @@ -94,7 +102,13 @@ public void Render(Screen screen, Theme theme) RenderQueuePanel(screen, new Rect(0, inputFrame.Y - queueHeight, screen.Cols, queueHeight), theme); } - var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - queueHeight - 4); + // 后台任务面板在排队面板之上(排队输入紧贴输入框 — 现有语义零变化;常驻任务是环境状态居一层之上) + if (backgroundHeight > 0) + { + BackgroundTasks.Render(screen, new Rect(0, inputFrame.Y - queueHeight - backgroundHeight, screen.Cols, backgroundHeight), theme); + } + + var messagesHeight = Math.Max(0, screen.Rows - footerHeight - suggestionHeight - queueHeight - backgroundHeight - 4); var messagesFrame = new Rect(0, 0, screen.Cols, messagesHeight); Messages.Render(screen, messagesFrame, theme); } diff --git a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs index 71b4d8c38..8e9254339 100644 --- a/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs +++ b/native_tui/tests/NativeTui.Host.Tests/InProcessEventConsumerTests.cs @@ -318,4 +318,21 @@ public void OnContextUsage_EnqueuesContextUsageEvent() Assert.Equal(200_000, usage.ContextWindowTokens); Assert.Equal(8, usage.PercentLeft); } + + /// M8-8 后台任务状态更新 — OnBackgroundTask 四参直传 BackgroundTask 内存事件 + /// ((kind,taskId) upsert 面板条目;接口同步由引擎侧 IEventConsumer DIM 完成)。 + [Fact] + public void OnBackgroundTask_EnqueuesBackgroundTaskEvent() + { + var consumer = new InProcessEventConsumer(); + + consumer.OnBackgroundTask("fork", "fork-123", "检查README", "running"); + + var events = consumer.DrainEvents(); + var task = Assert.IsType(Assert.Single(events)); + Assert.Equal("fork", task.Kind); + Assert.Equal("fork-123", task.TaskId); + Assert.Equal("检查README", task.Description); + Assert.Equal("running", task.State); + } } diff --git a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs index 8abe4e9d0..dfe213533 100644 --- a/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs +++ b/native_tui/tests/NativeTui.Tests/bridge/ReplBridgeDriverTests.cs @@ -225,7 +225,7 @@ public void Apply_Error_AppendsErrorMessageItemBeforeSpinner() [Fact] public void Apply_UnknownFutureEvent_DerivedTypeCoverageIsExhaustiveByConstruction() { - // 全部 12 种事件类型逐一应用不抛异常(switch 穷尽由编译器保证) + // 全部事件类型逐一应用不抛异常(switch 穷尽由编译器保证) BridgeEvent[] all = [ new BridgeEvent.SessionInfo("1.0.0", "m", "auto", true, "C:\\"), @@ -239,6 +239,7 @@ public void Apply_UnknownFutureEvent_DerivedTypeCoverageIsExhaustiveByConstructi new BridgeEvent.AssistantMessage("z"), new BridgeEvent.TurnCompleted(), new BridgeEvent.TurnInterrupted(), + new BridgeEvent.BackgroundTask("fork", "fork-1", "后台任务", "running"), new BridgeEvent.Error("e"), ]; foreach (var bridgeEvent in all) @@ -570,4 +571,61 @@ public void Apply_ContextUsage_DoesNotTouchNotification() Assert.Equal("◐ 中等 · /effort", _view.Footer.Notification); Assert.Equal("上下文告急 (8%) · 运行 /compact", _view.Footer.ContextWarning); } + + /// M8-8 后台任务常驻面板 — BackgroundTask 事件路由 view.BackgroundTasks 按 (kind,taskId) upsert: + /// 新任务插入面板条目。 + [Fact] + public void Apply_BackgroundTask_InsertsPanelEntry() + { + _driver.Apply(new BridgeEvent.BackgroundTask("fork", "fork-123", "检查README", "running")); + + var task = Assert.Single(_view.BackgroundTasks.Tasks); + Assert.Equal("fork", task.Kind); + Assert.Equal("fork-123", task.TaskId); + Assert.Equal("检查README", task.Description); + Assert.Equal("running", task.State); + } + + [Fact] + public void Apply_BackgroundTask_SameKeyUpsertsStateAndDescription() + { + _driver.Apply(new BridgeEvent.BackgroundTask("fork", "fork-123", "检查README", "running")); + + _driver.Apply(new BridgeEvent.BackgroundTask("fork", "fork-123", "检查README与文档", "completed")); + + var task = Assert.Single(_view.BackgroundTasks.Tasks); + Assert.Equal("completed", task.State); + Assert.Equal("检查README与文档", task.Description); + } + + [Fact] + public void Apply_BackgroundTask_TerminalEntrySurvivesTurnBoundary() + { + _driver.Apply(new BridgeEvent.BackgroundTask("fork", "fork-1", "检查", "completed")); + _driver.BeginTurn(); + + _driver.EndTurn(); + + // 常驻语义 — 回合边界不清理面板条目(区别于子代理组的定格脱离) + var task = Assert.Single(_view.BackgroundTasks.Tasks); + Assert.Equal("completed", task.State); + } + + /// 协议契约(与引擎侧约定写死)— background_task JSON 判别符反序列化 + Driver 路由面板。 + [Fact] + public void Apply_BackgroundTaskFromJson_ParsesAndRoutesToPanel() + { + var reader = new StringReader( + "{\"type\":\"background_task\",\"kind\":\"fork\",\"taskId\":\"fork-123\",\"description\":\"检查README\",\"state\":\"running\"}\n"); + var result = BridgeProtocol.TryReadEvent(reader, out var parsed, out var diagnostic); + + Assert.Equal(BridgeReadResult.Event, result); + Assert.Null(diagnostic); + _driver.Apply(parsed!); + var task = Assert.Single(_view.BackgroundTasks.Tasks); + Assert.Equal("fork", task.Kind); + Assert.Equal("fork-123", task.TaskId); + Assert.Equal("检查README", task.Description); + Assert.Equal("running", task.State); + } } diff --git a/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs new file mode 100644 index 000000000..d327570a1 --- /dev/null +++ b/native_tui/tests/NativeTui.Tests/repl/BackgroundTaskPanelTests.cs @@ -0,0 +1,226 @@ +namespace NativeTui.Tests.Repl; + +/// +/// 后台任务常驻面板测试(M8-8 TASK064 末项首版)— 对齐基准 CoordinatorAgentStatus.tsx:34-76 首版简化汉化: +/// upsert 语义((kind,taskId) 复合键更新/插入)、每任务一行渲染(● 状态色 + kind:描述 + dim 状态)、 +/// 终态驻留(completed/failed/cancelled 不驱逐)、空面板零渲染(MeasureHeight=0 — SpinnerItem.Hidden 先例)、 +/// 上限窗口 min(10,n) + "+N more" 溢出(MaxQueuedInputs=10 先例)、ReplView 挂载位置(排队面板之上 — 输入区上方)。 +/// +public sealed class BackgroundTaskPanelTests +{ + [Fact] + public void Upsert_NewTask_InsertsEntry() + { + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "fork-123", "检查README", "running"); + + var task = Assert.Single(panel.Tasks); + Assert.Equal("fork", task.Kind); + Assert.Equal("fork-123", task.TaskId); + Assert.Equal("检查README", task.Description); + Assert.Equal("running", task.State); + Assert.Equal(1, panel.MeasureHeight()); + } + + [Fact] + public void Upsert_ExistingKey_UpdatesStateAndDescription() + { + var panel = new BackgroundTaskPanel(); + panel.Upsert("fork", "fork-123", "检查README", "running"); + + panel.Upsert("fork", "fork-123", "检查README与文档", "completed"); + + Assert.Single(panel.Tasks); + Assert.Equal("completed", panel.Tasks[0].State); + Assert.Equal("检查README与文档", panel.Tasks[0].Description); + } + + [Fact] + public void Upsert_SameTaskIdWithDifferentKind_AreSeparateEntries() + { + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "task-1", "fork 任务", "running"); + panel.Upsert("teammate", "task-1", "teammate 任务", "running"); + + Assert.Equal(2, panel.Tasks.Count); + Assert.Equal(2, panel.MeasureHeight()); + } + + [Fact] + public void TerminalStates_StayInPanel() + { + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "fork-1", "检查", "completed"); + panel.Upsert("fork", "fork-2", "构建", "failed"); + panel.Upsert("teammate", "tm-1", "审阅", "cancelled"); + + // 终态驻留 — 首版不自动驱逐(基准 evictAfter 过期驱逐下批做) + Assert.Equal(3, panel.Tasks.Count); + } + + [Fact] + public void EmptyPanel_MeasuresZeroHeight() + { + var panel = new BackgroundTaskPanel(); + + Assert.Empty(panel.Tasks); + Assert.Equal(0, panel.VisibleCount); + Assert.Equal(0, panel.HiddenCount); + Assert.Equal(0, panel.MeasureHeight()); + } + + [Fact] + public void OverLimit_WindowPlusMoreHintRow() + { + var panel = new BackgroundTaskPanel(); + for (var i = 1; i <= 11; i++) + { + panel.Upsert("fork", $"fork-{i}", $"任务{i}", "running"); + } + + Assert.Equal(11, panel.Tasks.Count); + Assert.Equal(10, panel.VisibleCount); + Assert.Equal(1, panel.HiddenCount); + // 10 条可见行 + 1 行 "+1 more" 溢出提示 + Assert.Equal(11, panel.MeasureHeight()); + } + + [Fact] + public void Render_RunningTask_TextColorDotAndDimStatus() + { + using var screen = new Screen(cols: 40, rows: 1); + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "fork-123", "检查README", "running"); + panel.Render(screen, new Rect(0, 0, 40, 1), Theme.Dark); + + Assert.Equal("● fork: 检查README · 运行中", RowText.Of(screen, 0, 0, 40).TrimEnd()); + var textDot = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text }); + Assert.Equal(textDot, screen[0, 0].StyleId); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + // 状态段(" · 运行中")dim:● 2 列 + "fork: " 6 列 + "检查README" 10 列 → col 18 起 + Assert.Equal(dim, screen[0, 18].StyleId); + } + + [Fact] + public void Render_CompletedTask_DotUsesSuccessColor() + { + using var screen = new Screen(cols: 40, rows: 1); + var panel = new BackgroundTaskPanel(); + + panel.Upsert("teammate", "tm-1", "审阅PR", "completed"); + panel.Render(screen, new Rect(0, 0, 40, 1), Theme.Dark); + + Assert.Equal("● teammate: 审阅PR · 已完成", RowText.Of(screen, 0, 0, 40).TrimEnd()); + var success = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Success }); + Assert.Equal(success, screen[0, 0].StyleId); + } + + [Fact] + public void Render_FailedAndCancelledTasks_DotUsesErrorColor() + { + using var screen = new Screen(cols: 40, rows: 2); + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "f-1", "构建", "failed"); + panel.Upsert("fork", "f-2", "测试", "cancelled"); + panel.Render(screen, new Rect(0, 0, 40, 2), Theme.Dark); + + Assert.Equal("● fork: 构建 · 失败", RowText.Of(screen, 0, 0, 40).TrimEnd()); + Assert.Equal("● fork: 测试 · 已取消", RowText.Of(screen, 1, 0, 40).TrimEnd()); + var error = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Error }); + Assert.Equal(error, screen[0, 0].StyleId); + Assert.Equal(error, screen[1, 0].StyleId); + } + + [Fact] + public void Render_UnknownState_ShowsRawStateWithDefaultColor() + { + using var screen = new Screen(cols: 40, rows: 1); + var panel = new BackgroundTaskPanel(); + + panel.Upsert("fork", "f-9", "探测", "paused"); + panel.Render(screen, new Rect(0, 0, 40, 1), Theme.Dark); + + // 协议演进宽容 — 未知状态原样显示、色回退 running 形态(theme.Text) + Assert.Equal("● fork: 探测 · paused", RowText.Of(screen, 0, 0, 40).TrimEnd()); + var textDot = screen.Styles.GetOrAdd(new TextStyle { Foreground = Theme.Dark.Text }); + Assert.Equal(textDot, screen[0, 0].StyleId); + } + + [Fact] + public void Render_OverLimit_ShowsWindowWithMoreHintRow() + { + using var screen = new Screen(cols: 40, rows: 11); + var panel = new BackgroundTaskPanel(); + for (var i = 1; i <= 11; i++) + { + panel.Upsert("fork", $"fork-{i}", $"任务{i}", "running"); + } + + panel.Render(screen, new Rect(0, 0, 40, 11), Theme.Dark); + + // 前 10 条逐行渲染(插入序窗口),第 11 条不可见,末行 "+1 more" dim + Assert.Equal("● fork: 任务1 · 运行中", RowText.Of(screen, 0, 0, 40).TrimEnd()); + Assert.Equal("● fork: 任务10 · 运行中", RowText.Of(screen, 9, 0, 40).TrimEnd()); + Assert.Equal("+1 more", RowText.Of(screen, 10, 0, 40).TrimEnd()); + var dim = screen.Styles.GetOrAdd(new TextStyle { Dim = true }); + Assert.Equal(dim, screen[10, 0].StyleId); + } + + [Fact] + public void Render_EmptyPanel_ZeroOutput() + { + using var screen = new Screen(cols: 20, rows: 1); + var panel = new BackgroundTaskPanel(); + + panel.Render(screen, new Rect(0, 0, 20, 0), Theme.Dark); + + Assert.Equal(Cell.Blank, screen[0, 0]); + } + + [Fact] + public void ReplView_BackgroundPanelRendersAboveQueuePanel() + { + using var screen = new Screen(cols: 40, rows: 14); + var view = new ReplView { Footer = new FooterState { Mode = PermissionMode.Auto } }; + view.QueuedInputs = ["排队输入"]; + view.BackgroundTasks.Upsert("fork", "f-1", "检查README", "running"); + + view.Render(screen, Theme.Dark); + + // 自底向上:footer 13 / 输入底边线 12 / 文本 11 / 顶边线 10 / 排队面板 9 / 后台任务面板 8 + // (排队面板保持紧贴输入框 — 现有语义零变化;后台任务面板在其上一层) + Assert.Equal(new string('─', 40), RowText.Of(screen, 10, 0, 40)); + Assert.Equal("❯ 排队输入", RowText.Of(screen, 9, 0, 40).TrimEnd()); + Assert.Equal("● fork: 检查README · 运行中", RowText.Of(screen, 8, 0, 40).TrimEnd()); + } + + [Fact] + public void ReplView_EmptyBackgroundPanel_LayoutUnchanged() + { + using var screen = new Screen(cols: 40, rows: 10); + var view = new ReplView { Footer = new FooterState { Mode = PermissionMode.Auto } }; + + view.Render(screen, Theme.Dark); + + // 空面板零渲染 — 布局与无面板完全一致(footer 9 / 底边线 8 / 顶边线 6) + Assert.Equal(new string('─', 40), RowText.Of(screen, 6, 0, 40)); + Assert.Equal(new string('─', 40), RowText.Of(screen, 8, 0, 40)); + Assert.Equal(Cell.Blank, screen[5, 0]); + } + + [Fact] + public void ReplView_MeasureContentHeight_IncludesBackgroundPanelHeight() + { + var view = new ReplView { Footer = new FooterState { Mode = PermissionMode.Auto } }; + view.Messages.Add(new UserMessageItem("hello")); + view.BackgroundTasks.Upsert("fork", "f-1", "检查", "running"); + + // 消息 2 + 间隔 1 + 输入区 3 + 后台面板 1 + footer 1 = 8 + Assert.Equal(8, view.MeasureContentHeight(cols: 40)); + } +} diff --git a/test/unit/host.tests/GlobalUsings.cs b/test/unit/host.tests/GlobalUsings.cs index 1f9195171..68419c59c 100644 --- a/test/unit/host.tests/GlobalUsings.cs +++ b/test/unit/host.tests/GlobalUsings.cs @@ -20,6 +20,7 @@ global using Core.Hooks.Events; global using Core.Memdir; global using Core.Plugins; +global using Core.Scheduling.Tasks; global using Core.Policy; global using Core.Utils; global using Infrastructure.Localization; diff --git a/test/unit/host.tests/bridge/BackgroundTaskEventPumpTests.cs b/test/unit/host.tests/bridge/BackgroundTaskEventPumpTests.cs new file mode 100644 index 000000000..4cc453b8d --- /dev/null +++ b/test/unit/host.tests/bridge/BackgroundTaskEventPumpTests.cs @@ -0,0 +1,211 @@ +namespace Host.Tests.Bridge; + +/// +/// 后台任务事件泵测试(M8-8)— ForkCompleted/TeammateCompleted 事件 → OnBackgroundTask 四参 upsert、 +/// 快照全量补发、Bind 前事件丢弃、Start/Stop 幂等。 +/// fake 形态:Moq 模拟两查询面(Raise 触发完成事件 + 快照返回值),fake consumer 记录四参调用序列。 +/// +public sealed class BackgroundTaskEventPumpTests { + /// 记录 OnBackgroundTask 四参调用序列的 fake consumer(其余回调空实现)。 + private sealed class RecordingConsumer : IEventConsumer { + public List<(string Kind, string TaskId, string Description, string State)> Calls { get; } = []; + + public void OnText(string content) { } + public void OnThinking(string thinking) { } + public void OnToolStart(string toolName, string? toolCallId, string? arguments) { } + public void OnToolEnd(string toolName, string? resultText, string? toolCallId, bool isError, StructuredPatchHunk[]? patch) { } + public void OnToolProgress(string toolName, string progressType, string? progressMessage) { } + public void OnLoopDetected(int triggerCount, int loopStartIndex, string? repeatedPattern) { } + public void OnTimingSummary(string summary) { } + public void OnDone(TokenUsage? usage, string? modelId) { } + + public void OnBackgroundTask(string kind, string taskId, string description, string state) => + Calls.Add((kind, taskId, description, state)); + } + + private static ForkCompletedEventArgs ForkCompleted(ForkState state, string taskDescription = "检查README") => + new() { + ForkId = "fork-123", + State = state, + TaskDescription = taskDescription, + Result = "ok", + Error = state == ForkState.Failed ? "boom" : null, + }; + + private static Mock CreateForkManager(params ForkSubAgent[] activeForks) { + var mock = new Mock(); + mock.Setup(m => m.GetActiveForksAsync(It.IsAny())) + .ReturnsAsync(activeForks); + return mock; + } + + private static Mock CreateTeammateExecutor(params TeammateStateSnapshot[] snapshots) { + var mock = new Mock(); + mock.Setup(m => m.GetActiveTeammateSnapshotsAsync(It.IsAny())) + .ReturnsAsync(snapshots); + return mock; + } + + private static TeammateStateSnapshot TeammateSnapshot(string teammateId, string task = "调研渲染链") => + new(TeammateId: teammateId, ParentSessionId: "sess-1", Task: task, IsIdle: false, TurnCount: 3, LastResult: null); + + /// ForkCompleted → ("fork", forkId, taskDescription, state) 四参直推 — completed 终态。 + [Fact] + public async Task ForkCompleted_PushesFourPartUpsert() { + var forkManager = CreateForkManager(); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + pump.Bind(consumer); + await pump.StartAsync(); + + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(ForkState.Completed)); + + var call = Assert.Single(consumer.Calls); + Assert.Equal(("fork", "fork-123", "检查README", "completed"), call); + } + + /// ForkState 终态全映射 — failed/cancelled 透传 [EnumValue] 值,merged 折叠 completed。 + [Theory] + [InlineData(ForkState.Failed, "failed")] + [InlineData(ForkState.Cancelled, "cancelled")] + [InlineData(ForkState.Merged, "completed")] + public async Task ForkCompleted_MapsForkStateToProtocolStates(ForkState state, string expected) { + var forkManager = CreateForkManager(); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + pump.Bind(consumer); + await pump.StartAsync(); + + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(state)); + + Assert.Equal(expected, Assert.Single(consumer.Calls).State); + } + + /// TeammateCompleted → ("teammate", teammateId, task, 终态) — IsSuccess 二态映射 completed/failed。 + [Fact] + public async Task TeammateCompleted_PushesTeammateUpsert() { + var teammateExecutor = CreateTeammateExecutor(); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager: null, teammateExecutor: teammateExecutor.Object); + pump.Bind(consumer); + await pump.StartAsync(); + + teammateExecutor.Raise(m => m.TeammateCompleted += null, + new TeammateCompletedEventArgs { TeammateId = "teammate-9", Task = "调研渲染链", IsSuccess = true }); + teammateExecutor.Raise(m => m.TeammateCompleted += null, + new TeammateCompletedEventArgs { TeammateId = "teammate-8", Task = "修复构建", IsSuccess = false }); + + Assert.Equal( + [("teammate", "teammate-9", "调研渲染链", "completed"), ("teammate", "teammate-8", "修复构建", "failed")], + consumer.Calls); + } + + /// PublishSnapshotAsync — fork 快照按实际 State upsert(running 补发 + 终态幂等重发不倒退)、 + /// teammate 快照全发 running(活跃集合含 idle 等待者)。 + [Fact] + public async Task PublishSnapshot_UpsertsForksByState_AndTeammatesAsRunning() { + var forkManager = CreateForkManager( + new ForkSubAgent { ForkId = "fork-1", ParentSessionId = "sess-1", State = ForkState.Running, CreatedAt = DateTime.UtcNow }, + new ForkSubAgent { ForkId = "fork-2", ParentSessionId = "sess-1", State = ForkState.Completed, CreatedAt = DateTime.UtcNow, Result = "ok" }); + var teammateExecutor = CreateTeammateExecutor(TeammateSnapshot("teammate-1"), TeammateSnapshot("teammate-2", "写单测")); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor.Object); + pump.Bind(consumer); + + await pump.PublishSnapshotAsync(); + + Assert.Equal( + [ + ("fork", "fork-1", "", "running"), + ("fork", "fork-2", "", "completed"), + ("teammate", "teammate-1", "调研渲染链", "running"), + ("teammate", "teammate-2", "写单测", "running"), + ], + consumer.Calls); + } + + /// Bind 前到达的完成事件丢弃(consumer 未就绪零推送)— 组装顺序保证先 Bind 后 Start。 + [Fact] + public async Task ForkCompletedBeforeBind_IsDropped() { + var forkManager = CreateForkManager(); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + await pump.StartAsync(); + + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(ForkState.Completed)); + pump.Bind(consumer); + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(ForkState.Failed, taskDescription: "late")); + + Assert.Equal([("fork", "fork-123", "late", "failed")], consumer.Calls); + } + + /// StopAsync 退订后事件不再推送;DisposeAsync 等价 StopAsync(重复退订幂等)。 + [Fact] + public async Task StopAsync_Unsubscribes_AndIsIdempotent() { + var forkManager = CreateForkManager(); + var consumer = new RecordingConsumer(); + var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + pump.Bind(consumer); + await pump.StartAsync(); + + await pump.StopAsync(); + await pump.DisposeAsync(); + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(ForkState.Completed)); + + Assert.Empty(consumer.Calls); + } + + /// StartAsync 幂等 — 重复调用不产生双订阅(单事件单推送)。 + [Fact] + public async Task StartAsync_Twice_SubscribesOnce() { + var forkManager = CreateForkManager(); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + pump.Bind(consumer); + await pump.StartAsync(); + await pump.StartAsync(); + + forkManager.Raise(m => m.ForkCompleted += null, ForkCompleted(ForkState.Completed)); + + Assert.Single(consumer.Calls); + } + + /// PublishSnapshotAsync 在 Bind 前调用零推送(快照出口未就绪静默跳过)。 + [Fact] + public async Task PublishSnapshot_WithoutBind_IsNoOp() { + var forkManager = CreateForkManager( + new ForkSubAgent { ForkId = "fork-1", ParentSessionId = "sess-1", State = ForkState.Running, CreatedAt = DateTime.UtcNow }); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor: null); + + await pump.PublishSnapshotAsync(); + } + + /// 查询面抛异常降级不中断快照(另一面仍正常推送 — Actor 释放等场景防御)。 + [Fact] + public async Task PublishSnapshot_ForkQueryThrows_TeammatesStillPublished() { + var forkManager = new Mock(); + forkManager.Setup(m => m.GetActiveForksAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("actor disposed")); + var teammateExecutor = CreateTeammateExecutor(TeammateSnapshot("teammate-1")); + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager.Object, teammateExecutor.Object); + pump.Bind(consumer); + + await pump.PublishSnapshotAsync(); + + Assert.Equal([("teammate", "teammate-1", "调研渲染链", "running")], consumer.Calls); + } + + /// 两查询面均 null(引擎未注册)— 泵全程零行为,Bind/PublishSnapshot/Stop 均安全。 + [Fact] + public async Task NullManagers_PumpIsInert() { + var consumer = new RecordingConsumer(); + await using var pump = new BackgroundTaskEventPump(forkManager: null, teammateExecutor: null); + pump.Bind(consumer); + await pump.StartAsync(); + + await pump.PublishSnapshotAsync(); + + Assert.Empty(consumer.Calls); + } +} diff --git a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs index 92c34bd94..9996ac868 100644 --- a/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs +++ b/test/unit/host.tests/bridge/BridgeEventConsumerTests.cs @@ -42,6 +42,21 @@ public void OnContextUsage_EmitsContextUsageEventLine() { @out.ToString()); } + /// 后台任务状态更新映射(M8-8)— OnBackgroundTask → background_task 事件行 + /// (事件泵直推,四参 upsert 透传;与 native_tui 侧字段逐一对齐)。 + [Fact] + public void OnBackgroundTask_EmitsBackgroundTaskEventLine() { + var (consumer, @out) = Create(); + + consumer.OnBackgroundTask("fork", "fork-123", "检查README", "running"); + consumer.OnBackgroundTask("teammate", "teammate-9", "调研渲染链", "completed"); + + Assert.Equal( + "{\"type\":\"background_task\",\"kind\":\"fork\",\"taskId\":\"fork-123\",\"description\":\"检查README\",\"state\":\"running\"}\n" + + "{\"type\":\"background_task\",\"kind\":\"teammate\",\"taskId\":\"teammate-9\",\"description\":\"调研渲染链\",\"state\":\"completed\"}\n", + @out.ToString()); + } + [Fact] public void OnText_EmitsTokenDelta_AndOnDoneEmitsFullAssistantMessage() { var (consumer, @out) = Create(); diff --git a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs index 93b803180..b8ae8fd23 100644 --- a/test/unit/host.tests/bridge/BridgeJsonContractTests.cs +++ b/test/unit/host.tests/bridge/BridgeJsonContractTests.cs @@ -114,6 +114,18 @@ public void Serialize_ContextUsage_CarriesCamelCaseFields() { json); } + /// 后台任务事件 JSON 形状(M8-8 — 与 native_tui 侧 BridgeEvent.BackgroundTask 逐字段镜像: + /// background_task + kind/taskId/description/state camelCase 字段,协议契约快照)。 + [Fact] + public void Serialize_BackgroundTask_CarriesCamelCaseFields() { + var json = JsonSerializer.Serialize( + new CliBridge.BridgeEvent.BackgroundTask("fork", "fork-123", "检查README", "running"), + CliBridge.BridgeJsonContext.Safe.BridgeEvent); + Assert.Equal( + "{\"type\":\"background_task\",\"kind\":\"fork\",\"taskId\":\"fork-123\",\"description\":\"检查README\",\"state\":\"running\"}", + json); + } + /// 子代理三事件 JSON 形状(DSG041 批3 — 与 native_tui 侧 BridgeEvent 逐字段镜像: /// subagent_started/subagent_tool_use/subagent_finished,camelCase 字段)。 [Fact] From 9854cd987535199103cf12ff730728b78f687337 Mon Sep 17 00:00:00 2001 From: liuqihong <540762622@qq.com> Date: Thu, 22 Oct 2026 06:49:38 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20TASK064=20M8=20=E5=85=A8=E5=85=AB?= =?UTF-8?q?=E6=89=B9=E6=94=B6=E5=8F=A3=EF=BC=88M8-1..M8-8=20implemented?= =?UTF-8?q?=EF=BC=89=20|=20=E5=86=B3=E7=AD=96:=20=E5=B9=B6=E8=A1=8C?= =?UTF-8?q?=E5=AD=90=E4=BB=A3=E7=90=86=E5=8A=A0=E9=80=9F=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=85=A8=E7=A8=8B=E5=AE=9E=E8=AF=81=E2=80=94=E2=80=94P0=20?= =?UTF-8?q?=E4=B8=89=E8=BF=9E=E4=B8=B2=E8=A1=8C=20TDD=20=E5=90=8E,=20M8-4/?= =?UTF-8?q?5/6=20=E4=B8=89=E4=BB=A3=E7=90=86=E4=BA=92=E6=96=A5=E5=88=86?= =?UTF-8?q?=E5=B7=A5(1509=E8=A1=8C)=20+=20M8-7/M8-8=20=E5=8F=8C=E4=BE=A7?= =?UTF-8?q?=E5=8F=8C=E4=BB=A3=E7=90=86=E5=90=84=E4=B8=A4=E8=BD=AE(390+904?= =?UTF-8?q?=E8=A1=8C),=20=E5=8D=95=E4=BC=9A=E8=AF=9D=E4=BA=A4=E4=BB=98=20M?= =?UTF-8?q?8=20=E5=85=A8=E9=87=8F;=20=E9=81=97=E7=95=99=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=3Dconfirm/prompt=20=E4=BA=A4=E4=BA=92=E6=A8=A1=E6=80=81/evictA?= =?UTF-8?q?fter=20=E9=A9=B1=E9=80=90/=E5=81=9C=E6=AD=A2=E4=BA=A4=E4=BA=92/?= =?UTF-8?q?fork=20=E5=BF=AB=E7=85=A7=E6=8F=8F=E8=BF=B0/=E6=96=B9=E6=A1=88?= =?UTF-8?q?=20B=20=E5=9D=97=E7=BA=A7=20Markdown=E2=80=94=E2=80=94P3=20?= =?UTF-8?q?=E6=B1=A0=E9=9A=8F=E6=9C=BA=E4=BC=9A=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...\256\350\267\235\350\247\204\345\210\222.md" | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" index 1402a5336..76f096198 100644 --- "a/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" +++ "b/docs/proposed/task/TASK064-TUI\345\257\271\351\275\220ClaudeCode-M8\346\211\271\346\254\241\345\267\256\350\267\235\350\247\204\345\210\222.md" @@ -1,6 +1,6 @@ # TASK064 — TUI 对齐 Claude Code 下一批差距(M8 规划) -> 状态:**M8-1..M8-6 已 implemented**(2026-10-22;P0 三连 689f830b3→423187e9d→aef776136,M8-4/5/6 并行子代理批次 f59a6a99e);M8-7/M8-8 + P3 池待做(引擎侧调研已完成:context_usage 小-中 / background_task 快照+终态 中) +> 状态:**M8-1..M8-8 全部 implemented(2026-10-22)**;提交链:P0 三连 689f830b3→423187e9d→aef776136,M8-4/5/6 并行批次 f59a6a99e,M8-7 643a400e5,M8-8 7946994c3。剩余 P3 池不排期随机会动;已登记遗留=confirm/prompt 交互模态、evictAfter 终态驱逐、停止交互(x 键)、fork 快照描述字段、方案 B 块级增量 Markdown > 状态:proposed(2026-10-22;PR #446 合并后双调研挖掘——native_tui 现状侧 + 基准源码侧) > 来源:用户指令"挖掘还有什么没对齐 Claude Code TUI 功能的,构建新规划" > 双调研证据:①native_tui 逐文件实证(InProcessEventConsumer/ReplShell/ReplBridgeDriver 等)②基准源码逐项核实(claude-code-rev-main/src,文件:行号 见各项) @@ -118,6 +118,21 @@ **验收**:导航态 c/p 写入剪贴板可粘贴验证;操作条渲染像素对齐(上边框+间隔符)。 +**✅ M8-7 实施收口(2026-10-22,643a400e5)**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| 引擎侧:IEventConsumer.OnContextUsage DIM + ContextUsage 协议事件 + BridgeEventConsumer 显式实现 | SessionController.ProcessEvent onDone 发射(ChatUsageProcessor 在中间件链外且 Singleton 作用域错配弃) | ✅ | ✅ host.tests 41(发射 3 + 映射 + JSON 契约) | +| TUI 侧:ContextWarning 独立槽位 + ResolveContextWarning 三色(≤10 error/≤25 warning/>25 清空) | FooterBar 行 2 告警优先渲染 | ✅ | ✅ 13 新测试(路由 8 + 渲染 4 + 映射 1) | + +**✅ M8-8 实施收口(2026-10-22,7946994c3)**: + +| 基建实现 | 消费点 | 已实现 | 已验收 | +|---------|--------|--------|--------| +| 引擎侧:BackgroundTaskEventPump(订阅 ForkCompleted/TeammateCompleted 绕开回合作用域通道 + 回合终态快照) | BridgeServeLoop 可选参接线 + EngineTurnClient Consumer 出口(主收编) | ✅ | ✅ 泵 12 + consumer/契约 | +| TUI 侧:BackgroundTaskPanel 常驻面板(upsert/终态驻留/空面板零渲染/上限10窗口/三态色) | 排队面板上方渲染 | ✅ | ✅ 面板 15 + 路由 4 + 映射 1 | +| 批次回归 | — | ✅ | ✅ Core 919 + Host.Tests 76 + host.tests 1401 + selftest 真跑 EXIT=0 | + ### M8-7 上下文告警(P1,依赖:引擎侧 token 余量事件——需先调研引擎 contextWindow 消费链) - footer Notification 槽位真数据化:`N% 直到自动压缩`(dim) / `上下文不足 (N%) · 运行 /compact`(warning/error 色)