| 1 | // Package eventwire defines the shared frontend JSON contract for event.Event. |
| 2 | package eventwire |
| 3 | |
| 4 | import ( |
| 5 | "encoding/json" |
| 6 | |
| 7 | "reasonix/internal/billing" |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/provider" |
| 10 | ) |
| 11 | |
| 12 | // CompletionSummary is the JSON form of event.CompletionSummaryInfo. |
| 13 | type CompletionSummary struct { |
| 14 | Preset string `json:"preset"` // deprecated; pinned compat value |
| 15 | Verdict string `json:"verdict"` |
| 16 | Mutations int `json:"mutations"` |
| 17 | ChangedFiles int `json:"changed_files,omitempty"` |
| 18 | ChecksPassed int `json:"checks_passed"` |
| 19 | ChecksFailed int `json:"checks_failed"` |
| 20 | ChecksSuppressed int `json:"checks_suppressed"` |
| 21 | Review string `json:"review"` |
| 22 | GapKinds []string `json:"gap_kinds,omitempty"` |
| 23 | ConstraintDegraded bool `json:"constraint_degraded"` |
| 24 | Floor string `json:"floor,omitempty"` |
| 25 | Attention bool `json:"attention"` |
| 26 | } |
| 27 | |
| 28 | func toWireCompletionSummary(c *event.CompletionSummaryInfo) *CompletionSummary { |
| 29 | if c == nil { |
| 30 | return nil |
| 31 | } |
| 32 | return &CompletionSummary{ |
| 33 | Preset: c.Preset, |
| 34 | Verdict: c.Verdict, |
| 35 | Mutations: c.Mutations, |
| 36 | ChangedFiles: c.ChangedFiles, |
| 37 | ChecksPassed: c.ChecksPassed, |
| 38 | ChecksFailed: c.ChecksFailed, |
| 39 | ChecksSuppressed: c.ChecksSuppressed, |
| 40 | Review: c.Review, |
| 41 | GapKinds: append([]string(nil), c.GapKinds...), |
| 42 | ConstraintDegraded: c.ConstraintDegraded, |
| 43 | Floor: c.Floor, |
| 44 | Attention: c.Attention, |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | type WorkspaceChanged struct { |
| 49 | Revisions WorkspaceRevision `json:"revisions"` |
| 50 | Changes []WorkspacePathChange `json:"changes"` |
| 51 | AllPaths bool `json:"allPaths"` |
| 52 | Source string `json:"source"` |
| 53 | WatchState string `json:"watchState"` |
| 54 | } |
| 55 | |
| 56 | type WorkspaceRevision struct { |
| 57 | Content uint64 `json:"content"` |
| 58 | Tree uint64 `json:"tree"` |
| 59 | WorkingTree uint64 `json:"workingTree"` |
| 60 | GitMeta uint64 `json:"gitMeta"` |
| 61 | Session uint64 `json:"session"` |
| 62 | } |
| 63 | |
| 64 | type WorkspacePathChange struct { |
| 65 | Path string `json:"path"` |
| 66 | OldPath string `json:"oldPath,omitempty"` |
| 67 | Op string `json:"op"` |
| 68 | } |
| 69 | |
| 70 | // StreamAttempt is the JSON form of event.StreamAttemptInfo. |
| 71 | type StreamAttempt struct { |
| 72 | ID string `json:"id"` |
| 73 | Action string `json:"action"` // begin | discard | commit |
| 74 | Attempt int `json:"attempt,omitempty"` |
| 75 | Max int `json:"max,omitempty"` |
| 76 | Reason string `json:"reason,omitempty"` // connection_reset | premature_eof | idle_timeout |
| 77 | } |
| 78 | |
| 79 | // ToWire converts a typed runtime event into the shared frontend JSON contract. |
| 80 | func ToWire(e event.Event) Event { |
| 81 | w := Event{Kind: kindNames[e.Kind], MessageID: e.MessageID, AttemptID: e.AttemptID, Source: e.Source, PromptKind: e.PromptKind, TurnID: e.TurnID, Sequence: e.Sequence, Status: string(e.Status), Text: e.Text, Detail: e.Detail, Reasoning: e.Reasoning, ItemID: e.ItemID, SessionPath: e.SessionPath, SessionReset: e.SessionReset} |
| 82 | w.SessionID, w.RuntimeEpoch, w.SubmissionID = e.SessionID, e.RuntimeEpoch, e.SubmissionID |
| 83 | if e.ItemID != "" { |
| 84 | promptEvent := false |
| 85 | switch e.Kind { |
| 86 | case event.AskRequest: |
| 87 | promptEvent = true |
| 88 | w.PromptKind = "ask" |
| 89 | case event.ApprovalRequest: |
| 90 | promptEvent = true |
| 91 | w.PromptKind = e.Approval.Kind |
| 92 | if w.PromptKind == "" { |
| 93 | w.PromptKind = "approval" |
| 94 | } |
| 95 | case event.MCPInteractionRequest: |
| 96 | promptEvent = true |
| 97 | w.PromptKind = "mcp" |
| 98 | case event.PromptAnswered: |
| 99 | promptEvent = true |
| 100 | } |
| 101 | if promptEvent { |
| 102 | w.PromptID = e.ItemID |
| 103 | w.PromptLegacy = e.TurnID == "" |
| 104 | } |
| 105 | } |
| 106 | if len(e.MemoryCitations) > 0 { |
| 107 | w.MemoryCitations = ToWireMemoryCitations(e.MemoryCitations) |
| 108 | } |
| 109 | switch e.Kind { |
| 110 | case event.Notice: |
| 111 | w.applyNotice(e) |
| 112 | case event.ReadStatus: |
| 113 | w.ReadStatus = toWireReadStatus(e.ReadStatus) |
| 114 | case event.ToolDispatch, event.ToolStarted, event.ToolResult, event.ToolProgress, event.ToolResultPreview: |
| 115 | w.Tool = toWireTool(e.Tool) |
| 116 | case event.WorkspaceChanged: |
| 117 | ws := e.Workspace |
| 118 | if ws == nil { |
| 119 | ws = &event.WorkspaceChangedPayload{} |
| 120 | } |
| 121 | changes := make([]WorkspacePathChange, 0, len(ws.Changes)) |
| 122 | for _, c := range ws.Changes { |
| 123 | changes = append(changes, WorkspacePathChange{Path: c.Path, OldPath: c.OldPath, Op: c.Op}) |
| 124 | } |
| 125 | w.Workspace = &WorkspaceChanged{ |
| 126 | Revisions: WorkspaceRevision{Content: ws.Revisions.Content, Tree: ws.Revisions.Tree, WorkingTree: ws.Revisions.WorkingTree, GitMeta: ws.Revisions.GitMeta, Session: ws.Revisions.Session}, |
| 127 | Changes: changes, AllPaths: ws.AllPaths, Source: ws.Source, WatchState: string(ws.WatchState), |
| 128 | } |
| 129 | case event.Usage: |
| 130 | w.Usage = toWireUsage(e) |
| 131 | case event.ApprovalRequest: |
| 132 | w.Approval = toWireApproval(e.Approval) |
| 133 | case event.AskRequest: |
| 134 | w.Ask = ToWireAsk(e.Ask) |
| 135 | case event.MCPInteractionRequest: |
| 136 | w.MCPInteraction = ToWireMCPInteraction(e.MCPInteraction) |
| 137 | case event.CompactionStarted, event.CompactionDone: |
| 138 | w.Compaction = &Compaction{ |
| 139 | Trigger: e.Compaction.Trigger, Messages: e.Compaction.Messages, |
| 140 | Summary: e.Compaction.Summary, Archive: e.Compaction.Archive, |
| 141 | } |
| 142 | case event.ContextMaintenanceEvent: |
| 143 | if m := e.Maintenance; m != nil { |
| 144 | w.Maintenance = &ContextMaintenance{ |
| 145 | Status: m.Status, Action: m.Action, Trigger: m.Trigger, |
| 146 | OperationID: m.OperationID, InputTokens: m.InputTokens, |
| 147 | ResultTokens: m.ResultTokens, SavedTokens: m.SavedTokens, |
| 148 | AffectedToolResults: m.AffectedToolResults, |
| 149 | ProjectionVersion: m.ProjectionVersion, CacheBreak: m.CacheBreak, |
| 150 | Reason: m.Reason, |
| 151 | } |
| 152 | } |
| 153 | case event.GuardianAssessment: |
| 154 | w.Guardian = ToWireGuardian(e.Guardian) |
| 155 | case event.ExtensionSurface, event.ExtensionStatus: |
| 156 | w.Extension = ToWireExtensionSurface(e.Extension) |
| 157 | case event.TurnDone: |
| 158 | w.Recovery = e.Recovery |
| 159 | w.Outcome = e.Outcome |
| 160 | w.ReadPause = e.ReadPause |
| 161 | w.ReadCompletion = e.ReadCompletion |
| 162 | w.CheckpointTurn = e.CheckpointTurn |
| 163 | w.Receipt = completionReceiptWire(e.Receipt) |
| 164 | w.ProtocolRecovery = e.ProtocolRecovery |
| 165 | w.Diagnostic = e.Diagnostic |
| 166 | if e.Readiness != nil { |
| 167 | w.Readiness = &FinalReadiness{Attempts: e.Readiness.Attempts, Missing: append([]string(nil), e.Readiness.Missing...)} |
| 168 | } |
| 169 | if e.Err != nil { |
| 170 | w.Err = e.Err.Error() |
| 171 | } |
| 172 | case event.Retrying: |
| 173 | w.Recovery = e.Recovery |
| 174 | w.RetryAttempt = e.RetryAttempt |
| 175 | w.RetryMax = e.RetryMax |
| 176 | if e.RetryScope != "" { |
| 177 | w.RetryScope = string(e.RetryScope) |
| 178 | } |
| 179 | case event.StreamAttempt: |
| 180 | w.StreamAttempt = &StreamAttempt{ |
| 181 | ID: e.StreamAttempt.ID, |
| 182 | Action: string(e.StreamAttempt.Action), |
| 183 | Attempt: e.StreamAttempt.Attempt, |
| 184 | Max: e.StreamAttempt.Max, |
| 185 | Reason: e.StreamAttempt.Reason, |
| 186 | } |
| 187 | case event.TurnPhase: |
| 188 | w.Phase = string(e.PhaseName) |
| 189 | if w.Phase == "" { |
| 190 | w.Phase = e.Text |
| 191 | } |
| 192 | case event.CompletionSummary: |
| 193 | w.Completion = toWireCompletionSummary(e.Completion) |
| 194 | } |
| 195 | return w |
| 196 | } |
| 197 | |
| 198 | func toWireUsage(e event.Event) *Usage { |
| 199 | u := e.Usage |
| 200 | if u == nil { |
| 201 | return nil |
| 202 | } |
| 203 | wire := &Usage{ |
| 204 | PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, |
| 205 | TotalTokens: u.TotalTokens, CacheHitTokens: u.CacheHitTokens, |
| 206 | CacheMissTokens: u.CacheMissTokens, ReasoningTokens: u.ReasoningTokens, |
| 207 | Estimated: u.Estimated, |
| 208 | Source: e.UsageSource, |
| 209 | ContextPromptTokens: u.ContextPromptTokens, |
| 210 | ContextCompletionTokens: u.ContextCompletionTokens, |
| 211 | ContextReasoningTokens: u.ContextReasoningTokens, |
| 212 | ContextCacheHitTokens: u.ContextCacheHitTokens, |
| 213 | ContextCacheMissTokens: u.ContextCacheMissTokens, |
| 214 | SessionCacheHitTokens: e.SessionHit, SessionCacheMissTokens: e.SessionMiss, |
| 215 | } |
| 216 | if e.CacheDiagnostics != nil { |
| 217 | wire.CacheDiagnostics = ToWireCacheDiagnostics(e.CacheDiagnostics) |
| 218 | } |
| 219 | quote := e.CostQuote |
| 220 | if quote == nil && e.Pricing != nil { |
| 221 | quote = event.EnsureCostQuote(e, nil) |
| 222 | } |
| 223 | if quote != nil { |
| 224 | wire.CostQuote = quote |
| 225 | wire.CostComplete = quote.CostComplete |
| 226 | wire.DisplayComplete = quote.DisplayComplete |
| 227 | wire.DisplayStatus = quote.DisplayStatus |
| 228 | wire.AggregateMode = quote.AggregateMode |
| 229 | wire.OriginalTotals = append([]billing.Money(nil), quote.OriginalTotals...) |
| 230 | if quote.Selected != nil { |
| 231 | wire.Cost = quote.Selected.Float64() |
| 232 | wire.Currency = quote.LegacyCurrencySymbol() |
| 233 | wire.CostUSD = wire.Cost |
| 234 | wire.CurrencyCode = quote.LegacyCurrencyCode() |
| 235 | } |
| 236 | } |
| 237 | return wire |
| 238 | } |
| 239 | |
| 240 | // DecisionReceipt is the JSON form of a provider-excluded user decision. |
| 241 | type DecisionReceipt struct { |
| 242 | ID string `json:"id"` |
| 243 | Kind string `json:"kind"` |
| 244 | Tool string `json:"tool,omitempty"` |
| 245 | Subject string `json:"subject,omitempty"` |
| 246 | Outcome string `json:"outcome"` |
| 247 | } |
| 248 | |
| 249 | func ToWireDecisionReceipt(in *provider.DecisionReceipt) *DecisionReceipt { |
| 250 | if in == nil { |
| 251 | return nil |
| 252 | } |
| 253 | return &DecisionReceipt{ID: in.ID, Kind: in.Kind, Tool: in.Tool, Subject: in.Subject, Outcome: in.Outcome} |
| 254 | } |
| 255 | |
| 256 | type FinalReadiness struct { |
| 257 | Attempts int `json:"attempts,omitempty"` |
| 258 | Missing []string `json:"missing,omitempty"` |
| 259 | } |
| 260 | |
| 261 | // MemoryCitation is the JSON form of provider.MemoryCitation. |
| 262 | type MemoryCitation struct { |
| 263 | ID string `json:"id,omitempty"` |
| 264 | Source string `json:"source"` |
| 265 | LineStart int `json:"lineStart,omitempty"` |
| 266 | LineEnd int `json:"lineEnd,omitempty"` |
| 267 | Note string `json:"note,omitempty"` |
| 268 | Kind string `json:"kind,omitempty"` |
| 269 | } |
| 270 | |
| 271 | // ToWireMemoryCitations converts local memory references into frontend JSON. |
| 272 | func ToWireMemoryCitations(in []provider.MemoryCitation) []MemoryCitation { |
| 273 | out := make([]MemoryCitation, 0, len(in)) |
| 274 | for _, c := range in { |
| 275 | if c.Source == "" && c.ID == "" && c.Note == "" { |
| 276 | continue |
| 277 | } |
| 278 | out = append(out, MemoryCitation{ |
| 279 | ID: c.ID, |
| 280 | Source: c.Source, |
| 281 | LineStart: c.LineStart, |
| 282 | LineEnd: c.LineEnd, |
| 283 | Note: c.Note, |
| 284 | Kind: c.Kind, |
| 285 | }) |
| 286 | } |
| 287 | return out |
| 288 | } |
| 289 | |
| 290 | // Compaction is the JSON form of an event.Compaction. |
| 291 | type Compaction struct { |
| 292 | Trigger string `json:"trigger,omitempty"` |
| 293 | Messages int `json:"messages,omitempty"` |
| 294 | Summary string `json:"summary,omitempty" externalizable:"true"` |
| 295 | Archive string `json:"archive,omitempty" externalizable:"true"` |
| 296 | } |
| 297 | |
| 298 | // AskOption is one JSON-formatted choice in a structured ask request. |
| 299 | type AskOption struct { |
| 300 | Label string `json:"label"` |
| 301 | Description string `json:"description,omitempty" externalizable:"true"` |
| 302 | } |
| 303 | |
| 304 | // AskQuestion is one JSON-formatted structured ask question. |
| 305 | type AskQuestion struct { |
| 306 | ID string `json:"id"` |
| 307 | Header string `json:"header,omitempty"` |
| 308 | Prompt string `json:"prompt" externalizable:"true"` |
| 309 | Options []AskOption `json:"options"` |
| 310 | Multi bool `json:"multi,omitempty"` |
| 311 | } |
| 312 | |
| 313 | // Ask is the JSON form of an event.Ask. |
| 314 | type Ask struct { |
| 315 | ID string `json:"id"` |
| 316 | Questions []AskQuestion `json:"questions"` |
| 317 | TurnID string `json:"turnId,omitempty"` |
| 318 | } |
| 319 | |
| 320 | // MCPInteraction is the JSON form of an event.MCPInteraction: one |
| 321 | // server-initiated elicitation awaiting the user's accept/decline/cancel. |
| 322 | // Schema and URL come from the MCP server; form answers travel only in the |
| 323 | // resolve call, never on this event. |
| 324 | type MCPInteraction struct { |
| 325 | ID string `json:"id"` |
| 326 | Server string `json:"server"` |
| 327 | Mode string `json:"mode"` |
| 328 | Message string `json:"message" externalizable:"true"` |
| 329 | RequestedSchema json.RawMessage `json:"requestedSchema,omitempty"` |
| 330 | URL string `json:"url,omitempty"` |
| 331 | ElicitationID string `json:"elicitationId,omitempty"` |
| 332 | TurnID string `json:"turnId,omitempty"` |
| 333 | } |
| 334 | |
| 335 | // applyNotice fills the Notice-specific wire fields. |
| 336 | func (w *Event) applyNotice(e event.Event) { |
| 337 | w.Code = e.Code |
| 338 | if e.DecisionReceipt != nil { |
| 339 | w.DecisionReceipt = ToWireDecisionReceipt(e.DecisionReceipt) |
| 340 | } |
| 341 | if e.Level == event.LevelWarn { |
| 342 | w.Level = "warn" |
| 343 | } else { |
| 344 | w.Level = "info" |
| 345 | } |
| 346 | } |
| 347 | |
| 348 | // ToWireMCPInteraction converts event.MCPInteraction to its wire form. |
| 349 | func ToWireMCPInteraction(i event.MCPInteraction) *MCPInteraction { |
| 350 | return &MCPInteraction{ |
| 351 | ID: i.ID, Server: i.Server, Mode: i.Mode, Message: i.Message, |
| 352 | RequestedSchema: i.RequestedSchema, URL: i.URL, ElicitationID: i.ElicitationID, TurnID: i.TurnID, |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | // Profile carries the subagent model/effort resolved for a tool call. |
| 357 | type Profile struct { |
| 358 | Model string `json:"model,omitempty"` |
| 359 | Effort string `json:"effort,omitempty"` |
| 360 | } |
| 361 | |
| 362 | // ShellExecution is the JSON form of event.ShellExecution (local UI metadata). |
| 363 | type ShellExecution struct { |
| 364 | Kind string `json:"kind,omitempty"` |
| 365 | Shell string `json:"shell,omitempty"` |
| 366 | ShellVersion string `json:"shellVersion,omitempty"` |
| 367 | Platform string `json:"platform,omitempty"` |
| 368 | SupportsAndAnd bool `json:"supportsAndAnd"` |
| 369 | State string `json:"state,omitempty"` |
| 370 | FailurePhase string `json:"failurePhase,omitempty"` |
| 371 | ExitCode *int `json:"exitCode,omitempty"` |
| 372 | OutputTail string `json:"outputTail,omitempty"` |
| 373 | MutationRisk string `json:"mutationRisk,omitempty"` |
| 374 | Verification string `json:"verification,omitempty"` |
| 375 | DurationMs int64 `json:"durationMs,omitempty"` |
| 376 | } |
| 377 | |
| 378 | func toWireShellExecution(in *event.ShellExecution) *ShellExecution { |
| 379 | if in == nil { |
| 380 | return nil |
| 381 | } |
| 382 | out := &ShellExecution{ |
| 383 | Kind: in.Kind, Shell: in.Shell, ShellVersion: in.ShellVersion, |
| 384 | Platform: in.Platform, SupportsAndAnd: in.SupportsAndAnd, |
| 385 | State: in.State, FailurePhase: in.FailurePhase, |
| 386 | OutputTail: in.OutputTail, MutationRisk: in.MutationRisk, |
| 387 | Verification: in.Verification, DurationMs: in.DurationMs, |
| 388 | } |
| 389 | if in.ExitCode != nil { |
| 390 | code := *in.ExitCode |
| 391 | out.ExitCode = &code |
| 392 | } |
| 393 | return out |
| 394 | } |
| 395 | |
| 396 | // Usage is the JSON form of provider usage telemetry. |
| 397 | type Usage struct { |
| 398 | PromptTokens int `json:"promptTokens"` |
| 399 | CompletionTokens int `json:"completionTokens"` |
| 400 | TotalTokens int `json:"totalTokens"` |
| 401 | CacheHitTokens int `json:"cacheHitTokens"` |
| 402 | CacheMissTokens int `json:"cacheMissTokens"` |
| 403 | ReasoningTokens int `json:"reasoningTokens,omitempty"` |
| 404 | Estimated bool `json:"estimated,omitempty"` |
| 405 | Source string `json:"source,omitempty"` |
| 406 | CacheDiagnostics *CacheDiagnostics `json:"cacheDiagnostics,omitempty"` |
| 407 | // Session-cumulative cache tokens keep status displays steadier than one-turn values. |
| 408 | SessionCacheHitTokens int `json:"sessionCacheHitTokens"` |
| 409 | SessionCacheMissTokens int `json:"sessionCacheMissTokens"` |
| 410 | // Context* fields are the latest single-request shape for gauges/rebind. |
| 411 | // When omitted, clients fall back to the billable prompt/completion totals. |
| 412 | ContextPromptTokens int `json:"contextPromptTokens,omitempty"` |
| 413 | ContextCompletionTokens int `json:"contextCompletionTokens,omitempty"` |
| 414 | ContextReasoningTokens int `json:"contextReasoningTokens,omitempty"` |
| 415 | ContextCacheHitTokens int `json:"contextCacheHitTokens,omitempty"` |
| 416 | ContextCacheMissTokens int `json:"contextCacheMissTokens,omitempty"` |
| 417 | Cost float64 `json:"cost,omitempty"` |
| 418 | Currency string `json:"currency,omitempty"` |
| 419 | // CurrencyCode is the ISO code for Cost (preferred over symbol Currency). |
| 420 | CurrencyCode string `json:"currencyCode,omitempty"` |
| 421 | // CostUSD is a compatibility alias for older consumers; it mirrors Cost |
| 422 | // (selected display valuation) and does not imply USD. |
| 423 | CostUSD float64 `json:"costUsd,omitempty"` |
| 424 | // CostQuote is the structured host-side quote. New consumers must prefer it |
| 425 | // over cost/currency aliases. Never sent to model providers. |
| 426 | CostQuote *billing.CostQuote `json:"costQuote,omitempty"` |
| 427 | CostComplete bool `json:"costComplete,omitempty"` |
| 428 | DisplayComplete bool `json:"displayComplete,omitempty"` |
| 429 | DisplayStatus string `json:"displayStatus,omitempty"` |
| 430 | AggregateMode string `json:"aggregateMode,omitempty"` |
| 431 | OriginalTotals []billing.Money `json:"originalTotals,omitempty"` |
| 432 | } |
| 433 | |
| 434 | // CacheDiagnostics is the JSON form of cache prefix diagnostics. |
| 435 | type CacheDiagnostics struct { |
| 436 | PrefixHash string `json:"prefixHash"` |
| 437 | PrefixChanged bool `json:"prefixChanged"` |
| 438 | PrefixChangeReasons []string `json:"prefixChangeReasons,omitempty"` |
| 439 | SystemHash string `json:"systemHash"` |
| 440 | ToolsHash string `json:"toolsHash"` |
| 441 | LogRewriteVersion int `json:"logRewriteVersion"` |
| 442 | ToolSchemaTokens int `json:"toolSchemaTokens"` |
| 443 | CacheMissTokens int `json:"cacheMissTokens"` |
| 444 | CacheHitTokens int `json:"cacheHitTokens"` |
| 445 | SessionContext *SessionContextDiagnostics `json:"sessionContext,omitempty"` |
| 446 | } |
| 447 | |
| 448 | // Guardian is the JSON form of an event.GuardianResult. |
| 449 | type Guardian struct { |
| 450 | ID string `json:"id"` |
| 451 | Tool string `json:"tool"` |
| 452 | Subject string `json:"subject"` |
| 453 | Outcome string `json:"outcome"` |
| 454 | RiskLevel string `json:"risk_level,omitempty"` |
| 455 | UserAuthorization string `json:"user_authorization,omitempty"` |
| 456 | Rationale string `json:"rationale,omitempty" externalizable:"true"` |
| 457 | DurationMs int64 `json:"duration_ms,omitempty"` |
| 458 | Usage *Usage `json:"usage,omitempty"` |
| 459 | } |
| 460 | |
| 461 | // ToWireGuardian converts an event.GuardianResult into its JSON wire form. |
| 462 | func ToWireGuardian(g event.GuardianResult) *Guardian { |
| 463 | out := &Guardian{ |
| 464 | ID: g.ID, |
| 465 | Tool: g.Tool, |
| 466 | Subject: g.Subject, |
| 467 | Outcome: g.Outcome, |
| 468 | RiskLevel: g.RiskLevel, |
| 469 | UserAuthorization: g.UserAuthorization, |
| 470 | Rationale: g.Rationale, |
| 471 | DurationMs: g.DurationMs, |
| 472 | } |
| 473 | if u := g.Usage; u != nil { |
| 474 | out.Usage = &Usage{ |
| 475 | PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, |
| 476 | TotalTokens: u.TotalTokens, CacheHitTokens: u.CacheHitTokens, |
| 477 | CacheMissTokens: u.CacheMissTokens, ReasoningTokens: u.ReasoningTokens, |
| 478 | Estimated: u.Estimated, |
| 479 | } |
| 480 | if g.Pricing != nil { |
| 481 | q := event.EnsureCostQuote(event.Event{Kind: event.Usage, Usage: u, Pricing: g.Pricing}, nil) |
| 482 | if q != nil { |
| 483 | out.Usage.CostQuote = q |
| 484 | out.Usage.Cost = q.LegacyCostFloat() |
| 485 | out.Usage.Currency = q.LegacyCurrencySymbol() |
| 486 | out.Usage.CostUSD = out.Usage.Cost |
| 487 | out.Usage.CurrencyCode = q.LegacyCurrencyCode() |
| 488 | } |
| 489 | } |
| 490 | } |
| 491 | return out |
| 492 | } |
| 493 | |
| 494 | // ToWireAsk converts an event.Ask into its JSON wire form. |
| 495 | func ToWireAsk(a event.Ask) *Ask { |
| 496 | qs := make([]AskQuestion, len(a.Questions)) |
| 497 | for i, q := range a.Questions { |
| 498 | opts := make([]AskOption, len(q.Options)) |
| 499 | for j, o := range q.Options { |
| 500 | opts[j] = AskOption{Label: o.Label, Description: o.Description} |
| 501 | } |
| 502 | qs[i] = AskQuestion{ID: q.ID, Header: q.Header, Prompt: q.Prompt, Options: opts, Multi: q.Multi} |
| 503 | } |
| 504 | return &Ask{ID: a.ID, Questions: qs, TurnID: a.TurnID} |
| 505 | } |
| 506 | |
| 507 | // ToWireCacheDiagnostics converts cache diagnostics into their JSON wire form. |
| 508 | func ToWireCacheDiagnostics(d *event.CacheDiagnostics) *CacheDiagnostics { |
| 509 | out := &CacheDiagnostics{ |
| 510 | PrefixHash: d.PrefixHash, |
| 511 | PrefixChanged: d.PrefixChanged, |
| 512 | PrefixChangeReasons: append([]string(nil), d.PrefixChangeReasons...), |
| 513 | SystemHash: d.SystemHash, |
| 514 | ToolsHash: d.ToolsHash, |
| 515 | LogRewriteVersion: d.LogRewriteVersion, |
| 516 | ToolSchemaTokens: d.ToolSchemaTokens, |
| 517 | CacheMissTokens: d.CacheMissTokens, |
| 518 | CacheHitTokens: d.CacheHitTokens, |
| 519 | } |
| 520 | if sc := d.SessionContext; sc != nil { |
| 521 | section := func(in event.SessionContextSectionDiagnostics) SessionContextSectionDiagnostics { |
| 522 | return SessionContextSectionDiagnostics{Digest: in.Digest, Chars: in.Chars} |
| 523 | } |
| 524 | out.SessionContext = &SessionContextDiagnostics{ |
| 525 | Version: sc.Version, Digest: sc.Digest, TargetRole: sc.TargetRole, |
| 526 | Reasons: append([]string(nil), sc.Reasons...), |
| 527 | Environment: section(sc.Environment), Workspace: section(sc.Workspace), |
| 528 | BackgroundMemory: section(sc.BackgroundMemory), SkillsCatalog: section(sc.SkillsCatalog), |
| 529 | } |
| 530 | } |
| 531 | return out |
| 532 | } |
| 533 | |
| 534 | // KindNames returns every stable frontend event kind in event.Kind order. It is |
| 535 | // the protocol-neutral source used by consumers such as the Remote schema |
| 536 | // generator; callers receive a copy and may sort it without mutating eventwire. |
| 537 | func KindNames() []string { |
| 538 | names := make([]string, 0, int(event.KindCount)) |
| 539 | for kind := range event.KindCount { |
| 540 | if name, ok := kindNames[kind]; ok { |
| 541 | names = append(names, name) |
| 542 | } |
| 543 | } |
| 544 | return names |
| 545 | } |
| 546 | |
| 547 | // KindName returns the stable wire name of one event kind, or false for a |
| 548 | // kind outside the known set. |
| 549 | func KindName(kind event.Kind) (string, bool) { |
| 550 | name, ok := kindNames[kind] |
| 551 | return name, ok |
| 552 | } |
| 553 | |
| 554 | var kindNames = map[event.Kind]string{ |
| 555 | event.UserMessage: "user_message", |
| 556 | event.TurnStarted: "turn_started", |
| 557 | event.Reasoning: "reasoning", |
| 558 | event.Text: "text", |
| 559 | event.Message: "message", |
| 560 | event.ToolDispatch: "tool_dispatch", |
| 561 | event.ToolStarted: "tool_started", |
| 562 | event.ToolResult: "tool_result", |
| 563 | event.Usage: "usage", |
| 564 | event.Notice: "notice", |
| 565 | event.Phase: "phase", |
| 566 | event.ApprovalRequest: "approval_request", |
| 567 | event.AskRequest: "ask_request", |
| 568 | event.TurnDone: "turn_done", |
| 569 | event.CompactionStarted: "compaction_started", |
| 570 | event.CompactionDone: "compaction_done", |
| 571 | event.ToolProgress: "tool_progress", |
| 572 | event.MCPSurfaceReady: "mcp_surface_ready", |
| 573 | event.Retrying: "retrying", |
| 574 | event.Steer: "steer", |
| 575 | event.GuardianAssessment: "guardian_assessment", |
| 576 | event.ExtensionSurface: "extension_surface", |
| 577 | event.ExtensionStatus: "extension_status", |
| 578 | event.StreamAttempt: "stream_attempt", |
| 579 | event.ContextMaintenanceEvent: "context_maintenance", |
| 580 | event.WorkspaceChanged: "workspace_changed", |
| 581 | event.TurnPhase: "turn_phase", |
| 582 | event.CompletionSummary: "completion_summary", |
| 583 | event.ToolResultPreview: "tool_result_preview", |
| 584 | event.TurnStatusChanged: "turn_status", |
| 585 | event.MCPInteractionRequest: "mcp_interaction", |
| 586 | event.PromptAnswered: "prompt_answered", |
| 587 | event.SessionChanged: "session_changed", |
| 588 | event.ReadStatus: "read_status", |
| 589 | } |
| 590 | |
| 591 | // ContextMaintenance is the JSON form of event.ContextMaintenance. |
| 592 | type ContextMaintenance struct { |
| 593 | Status string `json:"status,omitempty"` |
| 594 | Action string `json:"action,omitempty"` |
| 595 | Trigger string `json:"trigger,omitempty"` |
| 596 | OperationID string `json:"operationId,omitempty"` |
| 597 | InputTokens int `json:"inputTokens,omitempty"` |
| 598 | ResultTokens int `json:"resultTokens,omitempty"` |
| 599 | SavedTokens int `json:"savedTokens,omitempty"` |
| 600 | AffectedToolResults int `json:"affectedToolResults,omitempty"` |
| 601 | ProjectionVersion uint64 `json:"projectionVersion,omitempty"` |
| 602 | CacheBreak bool `json:"cacheBreak,omitempty"` |
| 603 | Reason string `json:"reason,omitempty"` |
| 604 | } |
| 605 | |
| 606 | // ExtensionSurface is the JSON form of an event.ExtensionSurfacePayload. |
| 607 | type ExtensionSurface struct { |
| 608 | PluginID string `json:"pluginId"` |
| 609 | SurfaceID string `json:"surfaceId"` |
| 610 | SessionID string `json:"sessionId,omitempty"` |
| 611 | Generation uint64 `json:"generation,omitempty"` |
| 612 | FormInstanceID string `json:"formInstanceId,omitempty"` |
| 613 | Kind string `json:"kind"` |
| 614 | Status *ExtensionStatus `json:"status,omitempty"` |
| 615 | Card *ExtensionCard `json:"card,omitempty"` |
| 616 | Form *ExtensionForm `json:"form,omitempty"` |
| 617 | Notification *ExtensionNotification `json:"notification,omitempty"` |
| 618 | } |
| 619 | |
| 620 | // ExtensionStatus is the JSON form of an event.ExtensionStatusView. |
| 621 | type ExtensionStatus struct { |
| 622 | Label string `json:"label"` |
| 623 | Detail string `json:"detail,omitempty"` |
| 624 | Severity string `json:"severity,omitempty"` |
| 625 | Progress *float64 `json:"progress,omitempty"` |
| 626 | } |
| 627 | |
| 628 | // ExtensionKeyValue is the JSON form of an event.ExtensionKeyValue. |
| 629 | type ExtensionKeyValue struct { |
| 630 | Key string `json:"key"` |
| 631 | Value string `json:"value"` |
| 632 | } |
| 633 | |
| 634 | // ExtensionActionRef is the JSON form of an event.ExtensionActionRef. |
| 635 | type ExtensionActionRef struct { |
| 636 | ActionID string `json:"actionId"` |
| 637 | Label string `json:"label"` |
| 638 | } |
| 639 | |
| 640 | // ExtensionCard is the JSON form of an event.ExtensionCardView. |
| 641 | type ExtensionCard struct { |
| 642 | Title string `json:"title,omitempty"` |
| 643 | Markdown string `json:"markdown,omitempty" externalizable:"true"` |
| 644 | Text string `json:"text,omitempty" externalizable:"true"` |
| 645 | Fields []ExtensionKeyValue `json:"fields,omitempty"` |
| 646 | Progress *float64 `json:"progress,omitempty"` |
| 647 | Actions []ExtensionActionRef `json:"actions,omitempty"` |
| 648 | } |
| 649 | |
| 650 | // ExtensionFormField is the JSON form of an event.ExtensionFormField. Default |
| 651 | // travels as raw JSON: the remote schema has no "any" type, and the field is |
| 652 | // already protocol-validated JSON on arrival. |
| 653 | type ExtensionFormField struct { |
| 654 | Key string `json:"key"` |
| 655 | Label string `json:"label,omitempty"` |
| 656 | Kind string `json:"kind,omitempty"` |
| 657 | Options []string `json:"options,omitempty"` |
| 658 | Default json.RawMessage `json:"default,omitempty"` |
| 659 | Required bool `json:"required,omitempty"` |
| 660 | } |
| 661 | |
| 662 | // ExtensionForm is the JSON form of an event.ExtensionFormView. |
| 663 | type ExtensionForm struct { |
| 664 | Title string `json:"title,omitempty"` |
| 665 | Message string `json:"message,omitempty" externalizable:"true"` |
| 666 | Fields []ExtensionFormField `json:"fields"` |
| 667 | } |
| 668 | |
| 669 | // ExtensionNotification is the JSON form of an event.ExtensionNotificationView. |
| 670 | type ExtensionNotification struct { |
| 671 | Title string `json:"title"` |
| 672 | Body string `json:"body,omitempty" externalizable:"true"` |
| 673 | Severity string `json:"severity,omitempty"` |
| 674 | } |
| 675 | |
| 676 | // ToWireExtensionSurface converts an event.ExtensionSurfacePayload into its |
| 677 | // JSON wire form. A nil payload yields nil so a malformed event never |
| 678 | // marshals a half-filled extension object. |
| 679 | func ToWireExtensionSurface(p *event.ExtensionSurfacePayload) *ExtensionSurface { |
| 680 | if p == nil { |
| 681 | return nil |
| 682 | } |
| 683 | out := &ExtensionSurface{ |
| 684 | PluginID: p.PluginID, SurfaceID: p.SurfaceID, SessionID: p.SessionID, |
| 685 | Generation: p.Generation, FormInstanceID: p.FormInstanceID, Kind: p.Kind, |
| 686 | } |
| 687 | if s := p.Status; s != nil { |
| 688 | out.Status = &ExtensionStatus{Label: s.Label, Detail: s.Detail, Severity: s.Severity, Progress: s.Progress} |
| 689 | } |
| 690 | if c := p.Card; c != nil { |
| 691 | card := &ExtensionCard{ |
| 692 | Title: c.Title, Markdown: c.Markdown, Text: c.Text, Progress: c.Progress, |
| 693 | } |
| 694 | if len(c.Fields) > 0 { |
| 695 | card.Fields = make([]ExtensionKeyValue, len(c.Fields)) |
| 696 | for i, f := range c.Fields { |
| 697 | card.Fields[i] = ExtensionKeyValue{Key: f.Key, Value: f.Value} |
| 698 | } |
| 699 | } |
| 700 | if len(c.Actions) > 0 { |
| 701 | card.Actions = make([]ExtensionActionRef, len(c.Actions)) |
| 702 | for i, a := range c.Actions { |
| 703 | card.Actions[i] = ExtensionActionRef{ActionID: a.ActionID, Label: a.Label} |
| 704 | } |
| 705 | } |
| 706 | out.Card = card |
| 707 | } |
| 708 | if f := p.Form; f != nil { |
| 709 | form := &ExtensionForm{Title: f.Title, Message: f.Message} |
| 710 | if len(f.Fields) > 0 { |
| 711 | form.Fields = make([]ExtensionFormField, len(f.Fields)) |
| 712 | for i, field := range f.Fields { |
| 713 | wireField := ExtensionFormField{ |
| 714 | Key: field.Key, Label: field.Label, Kind: field.Kind, |
| 715 | Options: append([]string(nil), field.Options...), |
| 716 | Required: field.Required, |
| 717 | } |
| 718 | if field.Default != nil { |
| 719 | // The value arrived as protocol-validated JSON, so a |
| 720 | // marshal failure here is unreachable in practice; a |
| 721 | // pathological in-memory value simply drops the default. |
| 722 | if raw, err := json.Marshal(field.Default); err == nil { |
| 723 | wireField.Default = raw |
| 724 | } |
| 725 | } |
| 726 | form.Fields[i] = wireField |
| 727 | } |
| 728 | } |
| 729 | out.Form = form |
| 730 | } |
| 731 | if n := p.Notification; n != nil { |
| 732 | out.Notification = &ExtensionNotification{Title: n.Title, Body: n.Body, Severity: n.Severity} |
| 733 | } |
| 734 | return out |
| 735 | } |
| 736 |