using Capacitor.Cli.Core; using Microsoft.Extensions.Logging; namespace Capacitor.Cli.Daemon.Acp; /// /// Pure, static, per-update translator from a reduced /// (daemon-local) to the daemon-local wire /// (Core — field-for-field mirror of the server's /// Capacitor.Server.Core.Acp.AcpEventEnvelope). Maps exactly ONE update to AT MOST one /// envelope (see docs/ai688-option-b-canonical-surfacing-design.md). Deliberately does /// NOT aggregate chunk streams, correlate multi-update tool-call state, assign real sequence /// numbers, or forward anything — those are handled elsewhere (aggregation/seq assignment is /// runtime-owned; seq/timestampIso below are caller-supplied /// inputs, not derived here). never throws: an unmappable/dropped kind /// returns rather than fabricating an empty envelope. /// internal static partial class AcpEventTranslator { /// /// ACP ToolCallStatus values that represent a FINISHED tool call — /// pending/in_progress/a missing status are non-terminal, status-only updates that /// never emit a ToolResult envelope, regardless of whether /// happens to be set. /// static bool IsTerminalToolStatus(string? status) => status is "completed" or "failed"; /// /// Translates ONE into an , or /// when the kind is dropped: /// /// → , /// → — /// both use when supplied (the caller's chunk-aggregation /// result — this translator holds no aggregation state of its own), else the update's own /// . /// → , /// carrying / /// (as — ACP's tool_call has no separate machine /// "name" field, only title/kind; title is the closest analogue and is what /// the server's AssistantToolCallsGenerated.ToolCallInfo.ToolName expects to /// display)/, plus the update's own kind /// normalized onto 's closed set — the agent supplies it, so an /// unrecognised token becomes other rather than reaching a consumer that switches on /// the vocabulary. /// → /// ONLY when the status is terminal () AND /// is non-null — a status-only update (non-terminal, /// or terminal with no extractable content) returns rather than an empty /// ToolResultReceived. /// → /// when the update carries a non-blank (the agent's /// auto-generated session title), else . /// / /// → always (not transcript content; deferred to future work). /// → , but /// logged via (when supplied) at debug level — never silently /// swallowed. itself is only logged verbatim when /// is (the operator-opt-in /// KCAP_ACP_DEBUG_FRAMES); otherwise only its length is logged, since an unknown update's /// raw JSON can carry prompt/tool/file content. /// /// Every emitted envelope carries / and the /// default ContractVersion = 1 (the record's own default — /// this translator never overrides it, since v1 is the only contract version this daemon speaks). /// public static AcpEventEnvelope? Translate( AcpSessionUpdate update, long seq, string timestampIso, string? aggregatedText = null, ILogger? logger = null, bool debugFrames = false, string? resolvedModel = null) { switch (update.Kind) { case AcpUpdateKind.AgentMessageChunk: return new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.AssistantText, Text: aggregatedText ?? update.Text, TimestampIso: timestampIso); case AcpUpdateKind.AgentThoughtChunk: return new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.AssistantThinking, Text: aggregatedText ?? update.Text, TimestampIso: timestampIso); case AcpUpdateKind.ToolCall: return new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.ToolCall, ToolCallId: update.ToolCallId, ToolName: update.ToolTitle, ToolInputJson: update.ToolInputJson, ToolKind: AcpToolKind.Normalize(update.ToolKind), TimestampIso: timestampIso); case AcpUpdateKind.ToolCallUpdate: if (!IsTerminalToolStatus(update.ToolStatus) || update.ToolResultText is null) return null; // status-only update — never an empty ToolResultReceived return new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.ToolResult, ToolCallId: update.ToolCallId, ToolResult: update.ToolResultText, ToolIsError: update.ToolIsError, TimestampIso: timestampIso); case AcpUpdateKind.UsageUpdate: // Reduce() already validated both fields, so a UsageUpdate here is always emittable. // The resolved model is stamped on EVERY usage envelope: the server's mapper is a // pure per-envelope function with no session-fold access, so attribution has to // travel on the wire rather than be looked up server-side. A null model is // harmless - the chip's denominator is the reading's own window. return new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.Usage, Model: resolvedModel, ContextUsedTokens: update.ContextUsedTokens, ContextWindowTokens: update.ContextWindowTokens, TimestampIso: timestampIso); case AcpUpdateKind.SessionInfo: // session_info_update carries the agent's auto-generated session title. Emit a // SessionTitle envelope only when a non-blank title is present; a title-less info // update has nothing to surface, so it is dropped like the other content-free kinds. return string.IsNullOrWhiteSpace(update.Title) ? null : new AcpEventEnvelope( Seq: seq, Kind: AcpEventKind.SessionTitle, Text: update.Title, TimestampIso: timestampIso); case AcpUpdateKind.Plan: case AcpUpdateKind.AvailableCommands: return null; case AcpUpdateKind.Unknown: default: // Gate on IsEnabled(Debug) first so the default path never materializes the raw JSON // (GetRawText allocates the full payload) when Debug logging is disabled. if (logger is not null && logger.IsEnabled(LogLevel.Debug)) { // KCAP_ACP_DEBUG_FRAMES gate (Off by default): the raw update JSON can carry // prompt/tool/file content, so it is only ever logged verbatim when the operator // has explicitly opted in — otherwise this logs shape (length) only. if (debugFrames) LogUnknownUpdateFull(logger, AcpDebugFrameLog.Cap(update.Raw?.GetRawText() ?? "")); else LogUnknownUpdateShape(logger, update.Raw?.GetRawText()?.Length ?? 0); } return null; } } [LoggerMessage(Level = LogLevel.Debug, Message = "ACP: dropping unrecognized session/update kind (Unknown); Raw={Raw}")] static partial void LogUnknownUpdateFull(ILogger logger, string raw); [LoggerMessage(Level = LogLevel.Debug, Message = "ACP: dropping unrecognized session/update kind (Unknown); RawLength={RawLength} chars")] static partial void LogUnknownUpdateShape(ILogger logger, int rawLength); /// /// Builds the daemon-synthesized SessionStarted envelope — NOT derived /// from an (ACP's session/update stream never carries a /// session-started variant). The orchestrator calls this exactly once per session, at /// Seq 0, AFTER agent registration and paired with the AcpSessionStarted hub bind — /// this builder itself has no ordering opinion; it is a pure envelope constructor. /// public static AcpEventEnvelope BuildSessionStarted( long seq, string timestampIso, string? cwd = null, string? model = null, string? rawSessionId = null, string? sessionMode = null, HostedSessionRepository? repository = null) => new( Seq: seq, Kind: AcpEventKind.SessionStarted, Cwd: cwd, Model: model, RawSessionId: rawSessionId, SessionMode: sessionMode, TimestampIso: timestampIso, RepoOwner: repository?.Owner, RepoName: repository?.Name, RepoRemoteUrl: repository?.RemoteUrl, RepoBranch: repository?.Branch); /// /// Builds the daemon-synthesized UserMessage envelope — one per /// serialized prompt turn, since ACP's session/prompt request itself never /// round-trips through session/update and so has no natural "update" to translate. /// public static AcpEventEnvelope BuildUserMessage(long seq, string timestampIso, string text) => new( Seq: seq, Kind: AcpEventKind.UserMessage, Text: text, TimestampIso: timestampIso); }