| 1 | #nullable enable |
| 2 | |
| 3 | using CascadeIDE.Contracts; |
| 4 | |
| 5 | namespace CascadeIDE.Features.Chat.Application; |
| 6 | |
| 7 | /// <summary> |
| 8 | /// Сценарий отправки сообщения Intercom: слэш → сборка outbound → spine → лента → провайдер (ADR 0119). |
| 9 | /// Трассировка только на границах фаз. |
| 10 | /// </summary> |
| 11 | [ApplicationOrchestrator("intercom-outbound-send")] |
| 12 | [UiThreadMarshal("host callbacks marshal UI where needed")] |
| 13 | public static class IntercomOutboundSendOrchestrator |
| 14 | { |
| 15 | public static Task RunAsync(IntercomOutboundSendHost host, CancellationToken cancellationToken = default) => |
| 16 | IntercomSendTrace.RunAsync(host.GetWorkspaceRoot(), IntercomSendPhases.SendChat.Root, rootPhase => |
| 17 | runCoreAsync(host, rootPhase, cancellationToken)); |
| 18 | |
| 19 | private static async Task runCoreAsync( |
| 20 | IntercomOutboundSendHost host, |
| 21 | IntercomSendPhase rootPhase, |
| 22 | CancellationToken cancellationToken) |
| 23 | { |
| 24 | var rawInput = host.GetTrimmedInput(); |
| 25 | if (string.IsNullOrEmpty(rawInput)) |
| 26 | return; |
| 27 | |
| 28 | var workspaceRoot = host.GetWorkspaceRoot(); |
| 29 | rootPhase.Detail($"input_len={rawInput.Length} pending_attach={host.GetPendingAttachCount()}"); |
| 30 | |
| 31 | var slashHandled = await IntercomSendTrace.RunAsync( |
| 32 | workspaceRoot, |
| 33 | IntercomSendPhases.SendChat.Slash, |
| 34 | _ => host.TryHandleSlashLineAsync(rawInput)).ConfigureAwait(false); |
| 35 | if (slashHandled) |
| 36 | return; |
| 37 | |
| 38 | (bool Ok, IntercomAttachmentMessageBuilder.Outbound Outbound, string Error) build; |
| 39 | await host.BeginPrepareOutboundAsync().ConfigureAwait(false); |
| 40 | try |
| 41 | { |
| 42 | build = await IntercomSendTrace.RunAsync( |
| 43 | workspaceRoot, |
| 44 | IntercomSendPhases.SendChat.BuildOutbound, |
| 45 | async buildPhase => |
| 46 | { |
| 47 | var result = await host.TryBuildOutboundAsync(rawInput, cancellationToken).ConfigureAwait(false); |
| 48 | if (!result.Ok) |
| 49 | { |
| 50 | buildPhase.Detail("failed: " + result.Error); |
| 51 | return result; |
| 52 | } |
| 53 | |
| 54 | buildPhase.Detail( |
| 55 | $"ok attachments={result.Outbound.Attachments.Count} content_len={result.Outbound.Content.Length}"); |
| 56 | return result; |
| 57 | }).ConfigureAwait(false); |
| 58 | } |
| 59 | finally |
| 60 | { |
| 61 | await host.EndPrepareOutboundAsync().ConfigureAwait(false); |
| 62 | } |
| 63 | |
| 64 | if (!build.Ok) |
| 65 | { |
| 66 | await host.SetClarificationStatusAsync(build.Error).ConfigureAwait(false); |
| 67 | return; |
| 68 | } |
| 69 | |
| 70 | var prepared = IntercomSendTrace.Run( |
| 71 | workspaceRoot, |
| 72 | IntercomSendPhases.SendChat.PrepareMessage, |
| 73 | preparePhase => |
| 74 | { |
| 75 | var display = host.ApplyProductSpine(build.Outbound.Content); |
| 76 | if (string.IsNullOrEmpty(display)) |
| 77 | return (Display: (string?)null, Agent: (string?)null); |
| 78 | |
| 79 | var agent = host.FormatAgentInput(display, build.Outbound); |
| 80 | preparePhase.Detail($"display_len={display.Length} agent_len={agent.Length}"); |
| 81 | return (Display: display, Agent: agent); |
| 82 | }); |
| 83 | |
| 84 | if (prepared.Display is null) |
| 85 | { |
| 86 | await host.SetClarificationStatusAsync("Сообщение пустое после подготовки.").ConfigureAwait(false); |
| 87 | return; |
| 88 | } |
| 89 | |
| 90 | var displayInput = prepared.Display; |
| 91 | var agentInput = prepared.Agent!; |
| 92 | var mcpOnly = host.GetChatMcpOnly(); |
| 93 | var deliveryMode = host.ConsumeDeliveryMode(); |
| 94 | host.CancelActiveTurnIfSteer(deliveryMode); |
| 95 | var deferProvider = host.ShouldDeferProviderDispatch(deliveryMode); |
| 96 | var startProviderLoading = !mcpOnly && !deferProvider; |
| 97 | var dispatchedProvider = false; |
| 98 | |
| 99 | try |
| 100 | { |
| 101 | await IntercomSendTrace.RunAsync( |
| 102 | workspaceRoot, |
| 103 | IntercomSendPhases.SendChat.CommitFeed, |
| 104 | _ => host.CommitUserMessageAsync(displayInput, build.Outbound, startProviderLoading, deliveryMode)) |
| 105 | .ConfigureAwait(false); |
| 106 | |
| 107 | if (mcpOnly) |
| 108 | return; |
| 109 | |
| 110 | if (deferProvider) |
| 111 | { |
| 112 | await host.EnqueueFollowUpAgentInputAsync(agentInput).ConfigureAwait(false); |
| 113 | return; |
| 114 | } |
| 115 | |
| 116 | dispatchedProvider = true; |
| 117 | await IntercomSendTrace.RunAsync( |
| 118 | workspaceRoot, |
| 119 | IntercomSendPhases.SendChat.DispatchProvider, |
| 120 | async dispatchPhase => |
| 121 | { |
| 122 | var provider = host.GetActiveAiProvider(); |
| 123 | dispatchPhase.Detail($"provider={provider} agent_input_len={agentInput.Length} delivery={deliveryMode}"); |
| 124 | |
| 125 | if (string.Equals(provider, "CursorACP", StringComparison.Ordinal)) |
| 126 | await host.SendCursorAcpAsync(agentInput).ConfigureAwait(false); |
| 127 | else |
| 128 | await host.SendStreamingAsync(agentInput, displayInput).ConfigureAwait(false); |
| 129 | }).ConfigureAwait(false); |
| 130 | } |
| 131 | catch (Exception ex) |
| 132 | { |
| 133 | rootPhase.Detail("error: " + ex); |
| 134 | throw; |
| 135 | } |
| 136 | finally |
| 137 | { |
| 138 | if (dispatchedProvider) |
| 139 | await host.EndProviderTurnAsync().ConfigureAwait(false); |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | |