package daemon
import (
"encoding/json"
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// ToolActivity records display-only execution evidence, not model-facing tool output.
type ToolActivity struct {
Kind string `json:"kind"` // "read" | "search" | "list" | "run"
Target string `json:"target"`
Failed bool `json:"failed,omitempty"`
}
type FileChange struct {
Path string `json:"path"`
Kind string `json:"kind"` // "diff" | "unavailable"
Diff string `json:"diff,omitempty"`
Reason string `json:"reason,omitempty"`
Added int `json:"added,omitempty"`
Removed int `json:"removed,omitempty"`
}
type ToolTrace struct {
Activities []ToolActivity `json:"activities"`
Changes []FileChange `json:"changes"`
Truncated bool `json:"truncated,omitempty"`
}
// ParseToolTrace validates and extracts a ToolTrace from untyped data.
func ParseToolTrace(raw any) *ToolTrace {
if raw == nil {
return nil
}
data, ok := raw.(json.RawMessage)
if !ok {
encoded, err := json.Marshal(raw)
if err != nil {
return nil
}
data = encoded
}
var trace ToolTrace
if err := json.Unmarshal(data, &trace); err != nil {
return nil
}
if len(trace.Activities) > 64 || len(trace.Changes) > 16 {
return nil
}
for _, act := range trace.Activities {
switch act.Kind {
case "read", "search", "list", "run":
default:
return nil
}
// Python bounds these strings by Unicode characters, not UTF-8 bytes.
if utf8.RuneCountInString(act.Target) > 1000 {
return nil
}
}
for _, ch := range trace.Changes {
if utf8.RuneCountInString(ch.Path) > 1000 {
return nil
}
switch ch.Kind {
case "unavailable":
if utf8.RuneCountInString(ch.Reason) > 1000 {
return nil
}
case "diff":
if utf8.RuneCountInString(ch.Diff) > 16000 || ch.Added < 0 || ch.Removed < 0 {
return nil
}
default:
return nil
}
}
return &trace
}
// ToolIntent indicates inferred intent of a tool call.
type ToolIntent struct {
Kind string `json:"kind"` // "write" | "edit" | "read" | "run"
Target string `json:"target"`
}
// ToolCodePreview is a window on a call's code: while it generates, the
// newest end; once it runs, the first line.
type ToolCodePreview struct {
Text string `json:"text"`
Offset int `json:"offset"`
}
type ToolProgress struct {
Intent *ToolIntent `json:"intent,omitempty"`
Code *ToolCodePreview `json:"code,omitempty"`
CallID string `json:"callId"`
Name string `json:"name"`
Phase string `json:"phase"` // "generating" | "running"
}
type EventType string
const (
EventReset EventType = "reset"
EventRetry EventType = "retry"
EventText EventType = "text"
EventError EventType = "error"
EventInterrupted EventType = "interrupted"
EventMessage EventType = "message"
EventUser EventType = "user"
EventTurnMembership EventType = "turn_membership"
EventTurnCompleted EventType = "turn_completed"
EventThinking EventType = "thinking"
EventNote EventType = "note"
EventCompacted EventType = "compacted"
EventToolProgress EventType = "tool_progress"
EventTool EventType = "tool"
EventUsage EventType = "usage"
// EventCommitted says transcript rows up to Seq now cover what was shown.
EventCommitted EventType = "committed"
)
type Usage struct {
Model string `json:"model,omitempty"`
RecordedAt *int64 `json:"recordedAt,omitempty"`
PromptTokens *int `json:"promptTokens,omitempty"`
CachedPromptTokens *int `json:"cachedPromptTokens,omitempty"`
CacheWriteTokens *int `json:"cacheWriteTokens,omitempty"`
CompletionTokens *int `json:"completionTokens,omitempty"`
TotalTokens *int `json:"totalTokens,omitempty"`
ElapsedMs *float64 `json:"elapsedMs,omitempty"`
TokensPerSecond *float64 `json:"tokensPerSecond,omitempty"`
// CacheFade is how CachedPromptTokens fades once the session goes quiet,
// in time order.
CacheFade []CacheStep `json:"cacheFade,omitempty"`
}
// CacheStep says that from At (unix ms) on, a request would read Cached
// tokens from the provider's cache; nil when that is no longer known.
type CacheStep struct {
Cached *int `json:"cached,omitempty"`
At int64 `json:"at"`
}
type StreamEvent struct {
OperationID string `json:"operationId,omitempty"`
Image *ImageMetadata `json:"image,omitempty"`
Usage *Usage `json:"usage,omitempty"`
ToolTrace *ToolTrace `json:"trace,omitempty"`
Timestamp *int64 `json:"timestamp,omitempty"`
ToolArgs map[string]any `json:"args,omitempty"`
Progress *ToolProgress `json:"progress,omitempty"`
TurnID string `json:"turnId,omitempty"`
Type EventType `json:"type"`
Text string `json:"text,omitempty"`
Role string `json:"role,omitempty"` // "assistant"
Source string `json:"source,omitempty"`
TriggeredAt string `json:"triggeredAt,omitempty"`
ClientID string `json:"clientId,omitempty"`
Summary string `json:"summary,omitempty"`
Strategy string `json:"strategy,omitempty"`
ToolName string `json:"name,omitempty"`
ToolResult string `json:"result,omitempty"`
SubmissionIDs []string `json:"submissionIds,omitempty"`
Evicted int `json:"evicted,omitempty"`
// ElapsedMs is how long a replayed thought took, when the daemon timed it.
ElapsedMs int64 `json:"elapsedMs,omitempty"`
// Seq is the newest transcript row an EventCommitted covers.
Seq int64 `json:"seq,omitempty"`
// Before and More describe the history a reset or page carried: Before is
// its first row (the cursor for the next older page), More whether one exists.
Before int64 `json:"before,omitempty"`
More bool `json:"more,omitempty"`
// Replayed marks an event from the transcript snapshot that follows a
// reset: history, which says nothing about what the session does now.
Replayed bool `json:"-"`
}
func sanitizeControlRunes(s string) string {
var sb strings.Builder
for _, r := range s {
if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
sb.WriteRune(' ')
} else {
sb.WriteRune(r)
}
}
return sb.String()
}
// validateToolProgress distinguishes a display reset from malformed known fields.
func validateToolProgress(raw json.RawMessage) error {
if string(raw) == "null" {
return nil
}
var progress struct {
CallID *string `json:"callId"`
Name *string `json:"name"`
Phase *string `json:"phase"`
Intent *struct {
Kind *string `json:"kind"`
Target *string `json:"target"`
} `json:"intent"`
Code *struct {
Text *string `json:"text"`
Offset *int `json:"offset"`
} `json:"code"`
}
if err := json.Unmarshal(raw, &progress); err != nil {
return fmt.Errorf("invalid tool progress: %w", err)
}
if progress.CallID == nil || progress.Name == nil || progress.Phase == nil {
return errors.New("invalid tool progress: missing callId, name, or phase")
}
if *progress.Phase != "running" && *progress.Phase != "generating" {
return errors.New("invalid tool progress phase")
}
if progress.Intent != nil && (progress.Intent.Kind == nil || progress.Intent.Target == nil) {
return errors.New("invalid tool progress intent")
}
if progress.Code != nil && (progress.Code.Text == nil || progress.Code.Offset == nil) {
return errors.New("invalid tool progress code")
}
return nil
}