코드 탭 첫 LLM 응답 대기 진단을 강화하고 heartbeat 상태를 노출한다

설치형 환경에서 Code 탭 작업이 오래 걸릴 때 첫 도구 호출 전에 정체되는 구간을 추적할 수 있도록 StreamingToolExecutionCoordinator에 대기 heartbeat와 첫 응답 수신 로그를 추가했다.

첫 응답 전에는 모델 요청 시작, 응답 대기 시간, 첫 응답 수신 시점을 AgentLoopWait 로그와 Thinking 이벤트로 남기고, 이후 응답 지연도 heartbeat로 표시하도록 조정했다. 함께 StreamingToolExecutionCoordinatorTests를 추가해 첫 응답 지연 시 heartbeat가 노출되는 경로와 빠른 응답 시 불필요한 heartbeat가 생기지 않는 경로를 고정했다.

README.md와 docs/DEVELOPMENT.md에 2026-04-15 14:55 (KST) 기준 이력을 반영했고, dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_llm_wait_diag\\ -p:IntermediateOutputPath=obj\\verify_llm_wait_diag\\ 경고 0/오류 0, dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "StreamingToolExecutionCoordinatorTests|AgentLoopLlmRequestPreparationServiceTests|AgentLoopIterationPreparationServiceTests" -p:OutputPath=bin\\verify_llm_wait_diag_tests\\ -p:IntermediateOutputPath=obj\\verify_llm_wait_diag_tests\\ 통과 6을 확인했다.
This commit is contained in:
2026-04-15 14:57:55 +09:00
parent d3b6b1a936
commit 99990b9778
4 changed files with 235 additions and 2 deletions

View File

@@ -0,0 +1,149 @@
using System.Runtime.CompilerServices;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
using FluentAssertions;
using Xunit;
namespace AxCopilot.Tests.Services;
public class StreamingToolExecutionCoordinatorTests
{
[Fact]
public async Task SendWithToolsWithRecoveryAsync_ShouldEmitWaitHeartbeatBeforeFirstStreamEvent()
{
var llm = new FakeLlmService
{
InitialDelay = TimeSpan.FromMilliseconds(90),
Events =
[
new ToolStreamEvent(ToolStreamEventKind.TextDelta, "clock"),
new ToolStreamEvent(ToolStreamEventKind.Completed)
]
};
var emitted = new List<AgentEvent>();
var coordinator = CreateCoordinator(
llm,
emitted,
firstResponseHeartbeatDelay: TimeSpan.FromMilliseconds(20),
responseHeartbeatInterval: TimeSpan.FromMilliseconds(20));
var result = await coordinator.SendWithToolsWithRecoveryAsync(
[new ChatMessage { Role = "user", Content = "make a clock page" }],
Array.Empty<IAgentTool>(),
CancellationToken.None,
"메인 루프 1",
onStreamEventAsync: _ => Task.CompletedTask);
result.Should().ContainSingle(block => block.Type == "text" && block.Text == "clock");
emitted.Should().Contain(evt =>
evt.Type == AgentEventType.Thinking &&
evt.Summary.Contains("모델 첫 응답을 기다리는 중입니다", StringComparison.Ordinal));
emitted.Should().Contain(evt =>
evt.Type == AgentEventType.Thinking &&
evt.Summary.Contains("모델 첫 응답을 받아 계속 진행합니다", StringComparison.Ordinal));
}
[Fact]
public async Task SendWithToolsWithRecoveryAsync_ShouldSkipWaitHeartbeatWhenFirstStreamEventIsQuick()
{
var llm = new FakeLlmService
{
InitialDelay = TimeSpan.FromMilliseconds(5),
Events =
[
new ToolStreamEvent(ToolStreamEventKind.TextDelta, "ok"),
new ToolStreamEvent(ToolStreamEventKind.Completed)
]
};
var emitted = new List<AgentEvent>();
var coordinator = CreateCoordinator(
llm,
emitted,
firstResponseHeartbeatDelay: TimeSpan.FromMilliseconds(80),
responseHeartbeatInterval: TimeSpan.FromMilliseconds(80));
var result = await coordinator.SendWithToolsWithRecoveryAsync(
[new ChatMessage { Role = "user", Content = "respond quickly" }],
Array.Empty<IAgentTool>(),
CancellationToken.None,
"메인 루프 1",
onStreamEventAsync: _ => Task.CompletedTask);
result.Should().ContainSingle(block => block.Type == "text" && block.Text == "ok");
emitted.Should().NotContain(evt =>
evt.Summary.Contains("모델 첫 응답을 기다리는 중입니다", StringComparison.Ordinal));
emitted.Should().Contain(evt =>
evt.Type == AgentEventType.Thinking &&
evt.Summary.Contains("모델에 요청하는 중입니다", StringComparison.Ordinal));
}
private static StreamingToolExecutionCoordinator CreateCoordinator(
ILlmService llm,
List<AgentEvent> emitted,
TimeSpan firstResponseHeartbeatDelay,
TimeSpan responseHeartbeatInterval)
{
return new StreamingToolExecutionCoordinator(
llm,
(name, _) => name,
(_, _, _, _, _) => Task.FromResult(ToolResult.Fail("unused")),
(type, toolName, summary) => emitted.Add(new AgentEvent
{
Type = type,
ToolName = toolName,
Summary = summary
}),
_ => false,
_ => false,
_ => false,
(_, _) => 0,
firstResponseHeartbeatDelay,
responseHeartbeatInterval);
}
private sealed class FakeLlmService : ILlmService
{
public TimeSpan InitialDelay { get; init; } = TimeSpan.Zero;
public List<ToolStreamEvent> Events { get; init; } = [];
public (string service, string model) GetCurrentModelInfo() => ("deepseek", "deepseek-chat");
public Task<string> SendAsync(List<ChatMessage> messages, CancellationToken ct = default) => Task.FromResult(string.Empty);
public async IAsyncEnumerable<string> StreamAsync(List<ChatMessage> messages, [EnumeratorCancellation] CancellationToken ct = default)
{
await Task.CompletedTask;
yield break;
}
public Task<(bool ok, string message)> TestConnectionAsync() => Task.FromResult((true, "ok"));
public void PushRouteOverride(string service, string model) { }
public void ClearRouteOverride() { }
public void PushInferenceOverride(string? service = null, string? model = null, double? temperature = null, string? reasoningEffort = null) { }
public void PopInferenceOverride() { }
public TokenUsage? LastTokenUsage => null;
public RuntimeConnectionSnapshot GetRuntimeConnectionSnapshot() => new("deepseek", "deepseek-chat", "", false, true);
public Task<List<ContentBlock>> SendWithToolsAsync(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct = default, bool forceToolCall = false, Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
=> Task.FromResult(new List<ContentBlock>());
public async IAsyncEnumerable<ToolStreamEvent> StreamWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
if (InitialDelay > TimeSpan.Zero)
await Task.Delay(InitialDelay, ct);
foreach (var evt in Events)
yield return evt;
}
public ModelExecutionProfileCatalog.ExecutionPolicy GetActiveExecutionPolicy()
=> ModelExecutionProfileCatalog.Get("balanced");
public string? SystemPrompt => null;
public void Dispose() { }
}
}