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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
namespace JoinCode.Abstractions.LLM.Chat;

/// <summary>
/// 工具进度中继(TASK065 P3 — Bash 工具流式进度引擎侧接线)— 工具执行网关 onProgress 回调 →
/// 主对话事件通道:SubAgentEventChannel.Current 在场时构造闭包,把 ToolProgressData 投影为
/// ChatStreamEvent.ToolProgress Emit(复用既有排空合流点 — QueryLoop WaitForTaskWithDrainAsync
/// 50ms 轮询 / SessionController 主代理 chunk 循环逐拍排空,零新管道);
/// 无通道(CLI 纯文本等无显示消费方场景)返回 null — gateway 走无进度路径行为零变化。
/// 通道事件 AgentId 为 null → SessionController 主 Switch onToolProgress 分流 → IEventConsumer.OnToolProgress
/// → bridge 模式 tool_progress 事件行 / 进程内直连内存事件(双消费形态)。
/// 闭包直接持有通道引用(不经 AsyncLocal 回读)— ShellExecutionMiddleware 的进度 Timer 在
/// ThreadPool 回调发射时同样可靠(ExecutionContext 捕获链无关)。
/// </summary>
public static class ToolProgressRelay
{
/// <summary>
/// 构造进度中继回调 — 通道在场时闭包捕获通道引用,进度到达即 Emit ChatStreamEvent.ToolProgress
/// (toolName/toolCallId 构造期捕获 — ToolProgressData 不携带工具名,由调用方闭包供给);
/// 无通道返回 null(调用方直传 gateway 第四参,gateway 判 null 跳过进度路径)。
/// </summary>
/// <param name="toolName">工具名称(进度事件归属 — 闭包捕获)</param>
/// <param name="toolCallId">工具调用标识(可选 — 协议侧暂不投影,通道事件携带供后续按调用路由)</param>
public static JoinCode.Abstractions.Tools.ToolProgressCallback? Build(string toolName, string? toolCallId = null)
{
var channel = SubAgentEventChannel.Current;
if (channel is null)
{
return null;
}

return progress => channel.Emit(ChatStreamEvent.ToolProgress(
toolName, progress.ProgressType, progress.Message ?? string.Empty, toolCallId));
}
}
4 changes: 3 additions & 1 deletion modules/apps/engine/adapters/BridgeEventConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ public void OnToolEnd(string toolName, string? resultText, string? toolCallId, b
WriteEvent(new Cli.Bridge.BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch), rawOutput, hiddenLines));
}

/// <summary>工具调用进度 — TUI 不消费,忽略。</summary>
/// <summary>工具调用进度 → tool_progress(TASK065 P3 — Bash/PowerShell 执行中输出快照流式上屏;
/// progressType 透传引擎侧既有类型;null message 空串投影 — TUI 侧更新为空尾巴自然收敛)。</summary>
public void OnToolProgress(string toolName, string progressType, string? progressMessage) {
WriteEvent(new Cli.Bridge.BridgeEvent.ToolProgress(toolName, progressType, progressMessage ?? string.Empty));
}

/// <summary>检测到循环输出 — TUI 不消费,忽略。</summary>
Expand Down
10 changes: 10 additions & 0 deletions modules/apps/engine/bridge/BridgeProtocol.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public sealed record Quit() : BridgeRequest;
[JsonDerivedType(typeof(BridgeEvent.ThinkingStarted), "thinking_started")]
[JsonDerivedType(typeof(BridgeEvent.ThinkingEnded), "thinking_ended")]
[JsonDerivedType(typeof(BridgeEvent.ToolCall), "tool_call")]
[JsonDerivedType(typeof(BridgeEvent.ToolProgress), "tool_progress")]
[JsonDerivedType(typeof(BridgeEvent.ToolResult), "tool_result")]
[JsonDerivedType(typeof(BridgeEvent.TokenDelta), "token_delta")]
[JsonDerivedType(typeof(BridgeEvent.AssistantMessage), "assistant_message")]
Expand Down Expand Up @@ -78,6 +79,15 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// <summary>工具调用消息(bullet + bold 工具名 + args)。</summary>
public sealed record ToolCall(string Name, string Args) : BridgeEvent;

/// <summary>
/// 工具执行中进度流式增量(TASK065 P3 — Bash/PowerShell 执行中实时输出,取代"卡住到结束才见结果"):
/// ProgressType 透传引擎侧既有类型(bash_progress/ps_progress — ShellExecutionMiddleware 每秒快照;
/// query_update/search_results_received — WebSearch 等既有发射方零改动直达);
/// Message 为进度文本(bash_progress 为当前 stdout 最近行快照 — 多行文本);
/// TUI 侧流式项原地更新,tool_result 到达时原位替换为终态结果项 — 与 native_tui 侧逐字段镜像。
/// </summary>
public sealed record ToolProgress(string ToolName, string ProgressType, string Message) : BridgeEvent;

/// <summary>
/// diff 行种类 — 数值对齐 native_tui DiffLineKind(Context=0/Added=1/Removed=2),
/// 与引擎 PatchLineType 同序可直转 byte;JSON 投影为数字。
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
namespace Core.Context;

/// <summary>
/// 工具进度中继测试(TASK065 P3 — Bash 工具流式进度引擎侧接线)—
/// ToolProgressRelay.Build:通道在场闭包 Emit ChatStreamEvent.ToolProgress(toolName/toolCallId
/// 构造期捕获 — ToolProgressData 不携带工具名);无通道返回 null(CLI 纯文本等场景 gateway
/// 走无进度路径行为零变化);闭包直持通道引用 — ThreadPool 回调(ShellExecutionMiddleware 进度
/// Timer)发射不依赖 AsyncLocal 回读,跨线程可靠。
/// </summary>
public sealed class ToolProgressRelayTests
{
/// <summary>无通道 — Build 返回 null(调用方直传 gateway 第四参,gateway 判 null 跳过进度路径)。</summary>
[Fact]
public void Build_WithoutChannel_ReturnsNull()
{
Assert.Null(ToolProgressRelay.Build("Bash"));
}

/// <summary>通道在场 — 回调 Emit ToolProgress 事件(toolName/toolCallId 构造期捕获 +
/// progressType/message 透传 — 字段全量校验)。</summary>
[Fact]
public void Build_WithChannel_EmitsToolProgressEvent()
{
var channel = new SubAgentEventChannel();
using (channel.EnterScope())
{
var relay = ToolProgressRelay.Build("Bash", "call-1");
if (relay is not { } emit)
{
throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
}

emit(new ToolProgressData {
ProgressType = "bash_progress",
ToolUseId = "bash_progress-0",
Message = "line1\nline2",
});
}

var evt = Assert.Single(channel.TryDrain());
Assert.Equal(ChatStreamEventType.ToolProgress, evt.Type);
Assert.Equal("Bash", evt.ToolName);
Assert.Equal("call-1", evt.ToolCallId);
Assert.Equal("bash_progress", evt.ProgressType);
Assert.Equal("line1\nline2", evt.ProgressMessage);
}

/// <summary>null message — 空串投影(消费侧事件形状恒定)。</summary>
[Fact]
public void Build_NullMessage_ProjectsEmptyString()
{
var channel = new SubAgentEventChannel();
using (channel.EnterScope())
{
var relay = ToolProgressRelay.Build("Bash");
if (relay is not { } emit) {
throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
}

emit(new ToolProgressData { ProgressType = "bash_progress", ToolUseId = "u" });
}

var evt = Assert.Single(channel.TryDrain());
Assert.Equal(string.Empty, evt.ProgressMessage);
}

/// <summary>ThreadPool 调用(ShellExecutionMiddleware 进度 Timer 语义)— 闭包直持通道引用,
/// 跨线程发射可靠(不依赖 AsyncLocal 回读 — ExecutionContext 捕获链无关)。</summary>
[Fact]
public async Task Relay_FromThreadPoolStillEmits()
{
var channel = new SubAgentEventChannel();
using (channel.EnterScope())
{
var relay = ToolProgressRelay.Build("Bash", "call-1");

if (relay is not { } emit) {
throw new Xunit.Sdk.XunitException("通道在场时 Build 应返回非空回调");
}

await Task.Run(() => emit(new ToolProgressData {
ProgressType = "bash_progress",
ToolUseId = "u",
Message = "pooled output",
}));
}

var evt = Assert.Single(channel.TryDrain());
Assert.Equal(ChatStreamEventType.ToolProgress, evt.Type);
Assert.Equal("pooled output", evt.ProgressMessage);
}

/// <summary>通道完成后 Emit 静默丢弃 — 迟到进度不泄漏(通道既有语义,relay 调用不抛)。</summary>
[Fact]
public void Relay_AfterChannelComplete_SilentlyDropped()
{
var channel = new SubAgentEventChannel();
ToolProgressCallback? relay;
using (channel.EnterScope())
{
relay = ToolProgressRelay.Build("Bash");
}

channel.Complete();

if (relay is { } emit) {
emit(new ToolProgressData { ProgressType = "bash_progress", ToolUseId = "u", Message = "late" });
}
Assert.Empty(channel.TryDrain());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ public async Task<ToolCallResult> ExecuteToolCallAsync(
};
}

var toolResult = await _toolExecutionGateway.ExecuteAsync(toolCallName, arguments ?? new Dictionary<string, JsonElement>(), ct).ConfigureAwait(false);
// 工具进度中继(TASK065 P3 — Bash 执行中输出流式上屏):通道在场时 gateway onProgress
// → ChatStreamEvent.ToolProgress Emit → QueryLoop 排空合流点实时 yield;无通道 null 零影响
var toolResult = await _toolExecutionGateway.ExecuteAsync(
toolCallName, arguments ?? new Dictionary<string, JsonElement>(), ct,
JoinCode.Abstractions.LLM.Chat.ToolProgressRelay.Build(toolCallName, toolCallId)).ConfigureAwait(false);

// 构建结果文本
string resultText;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -465,8 +465,13 @@ private async Task<ToolResult> ExecuteToolAsync(ToolCallRequest request, QueryOp
}

try {
// 工具进度中继(TASK065 P3 — 主代理路径 Bash 执行中输出流式上屏):SessionController
// 回合级 SubAgentEventChannel 作用域在场时 Emit 进度事件 → chunk 循环逐拍排空直投消费方;
// 无作用域(CLI 纯文本/独立调用)null 零影响。registry 兜底路径无 onProgress 形参 — 遗留不发射
var result = _toolExecutionGateway is not null
? await _toolExecutionGateway.ExecuteAsync(request.ToolName, request.Arguments, cancellationToken).ConfigureAwait(false)
? await _toolExecutionGateway.ExecuteAsync(
request.ToolName, request.Arguments, cancellationToken,
JoinCode.Abstractions.LLM.Chat.ToolProgressRelay.Build(request.ToolName)).ConfigureAwait(false)
: await _toolRegistry.ExecuteToolAsync(request.ToolName, request.Arguments, cancellationToken).ConfigureAwait(false);
options?.ProgressTracker?.RecordToolUse(request.ToolName);
return result;
Expand Down
9 changes: 6 additions & 3 deletions native_tui/apps/NativeTui.Host/InProcessEventConsumer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ namespace NativeTui.Host;
/// OnText → token_delta;OnThinking 回合内单次信号 + 墙钟差结算 thinking_ended;
/// OnToolStart/OnToolEnd → tool_call/tool_result(摘要截断 200 + error 前缀 + StructuredPatch → DiffHunk 投影 +

/// 原始输出预截断投影 raw_output/hidden_lines);OnDone → assistant_message(完整文本) + token_usage(用量汇总);
/// OnBackgroundTask → background_task(M8-8 常驻面板 upsert)。/// OnToolProgress/OnLoopDetected/OnTimingSummary 不消费。
/// 原始输出预截断投影 raw_output/hidden_lines);OnToolProgress → tool_progress(TASK065 P3 —
/// Bash 执行中输出快照流式上屏);OnDone → assistant_message(完整文本) + token_usage(用量汇总);
/// OnBackgroundTask → background_task(M8-8 常驻面板 upsert)。/// OnLoopDetected/OnTimingSummary 不消费。
/// </summary>
public sealed class InProcessEventConsumer : IResettableEventConsumer {
/// <summary>工具结果摘要截断上限(对齐 engine BridgeEventConsumer 的 200 字符显示截断)。</summary>
Expand Down Expand Up @@ -103,8 +104,10 @@ public void OnToolEnd(string toolName, string? resultText, string? toolCallId, b
_events.Enqueue(new BridgeEvent.ToolResult(BuildSummary(resultText, isError), ProjectPatch(patch), rawOutput, hiddenLines));
}

/// <summary>工具调用进度 — TUI 不消费,忽略(对齐 bridge 形态)。</summary>
/// <summary>工具调用进度 → ToolProgress(TASK065 P3 — Bash/PowerShell 执行中输出快照流式上屏;
/// progressType 透传引擎侧既有类型,null message 空串投影 — 对齐 bridge 形态逐行为镜像)。</summary>
public void OnToolProgress(string toolName, string progressType, string? progressMessage) {
_events.Enqueue(new BridgeEvent.ToolProgress(toolName, progressType, progressMessage ?? string.Empty));
}

/// <summary>检测到循环输出 — TUI 不消费,忽略(对齐 bridge 形态)。</summary>
Expand Down
10 changes: 10 additions & 0 deletions native_tui/src/NativeTui.Core/bridge/BridgeEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ namespace NativeTui.Core.Bridge;
[JsonDerivedType(typeof(ThinkingStarted), "thinking_started")]
[JsonDerivedType(typeof(ThinkingEnded), "thinking_ended")]
[JsonDerivedType(typeof(ToolCall), "tool_call")]
[JsonDerivedType(typeof(ToolProgress), "tool_progress")]
[JsonDerivedType(typeof(ToolResult), "tool_result")]
[JsonDerivedType(typeof(PermissionRequest), "permission_request")]
[JsonDerivedType(typeof(TokenDelta), "token_delta")]
Expand Down Expand Up @@ -53,6 +54,15 @@ public sealed record ThinkingEnded(long DurationMs) : BridgeEvent;
/// <summary>工具调用消息(bullet + bold 工具名 + args)。</summary>
public sealed record ToolCall(string Name, string Args) : BridgeEvent;

/// <summary>
/// 工具执行中进度流式增量(TASK065 P3 — Bash/PowerShell 执行中实时输出取代"卡住到结束才见结果"):
/// ProgressType 透传引擎侧既有类型(bash_progress/ps_progress/query_update/...);
/// Message 为进度文本(bash_progress 为当前 stdout 最近行快照 — 多行文本)。
/// Driver 路由流式项原地更新(ToolCallItem 之后的输出尾巴),tool_result 到达时原位替换为
/// ToolResultItem(对齐 StreamingTextItem 替换先例)— 与引擎侧 ToolProgress 逐字段镜像。
/// </summary>
public sealed record ToolProgress(string ToolName, string ProgressType, string Message) : BridgeEvent;

/// <summary>
/// 工具结果摘要(⎿ 缩进项);Patch 非空时渲染为结构化 diff 块(M7-2 StructuredDiff)。
/// Patch null 不输出字段(WhenWritingNull — 与引擎侧 ToolResult 契约逐字节一致,旧 JSON 兼容)。
Expand Down
Loading