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);
}