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 色)