Skip to main content

navi_core/
event.rs

1use crate::capability::CapabilityLedgerEntry;
2use crate::goal::types::GoalStatus;
3use crate::patch::PatchProposal;
4use crate::tool::{ToolInvocation, ToolResult};
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8/// One display item in a transient subagent transcript.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct SubagentTranscriptItem {
11    /// Kind of transcript item.
12    pub kind: SubagentTranscriptKind,
13    /// Main one-line item text.
14    pub title: String,
15    /// Optional secondary text, already compacted for UI display.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub detail: Option<String>,
18    /// Optional success state for completed work.
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub ok: Option<bool>,
21}
22
23/// Display item kind for a transient subagent transcript.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25pub enum SubagentTranscriptKind {
26    ToolRequested,
27    ToolCompleted,
28    Text,
29}
30
31/// A versioned runtime event emitted during agent execution.
32///
33/// Wraps a [`RuntimeEventKind`] with a schema version so consumers can handle
34/// forward-compatible event streams.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct RuntimeEvent {
37    /// Event schema version. Currently `1`.
38    #[serde(default)]
39    pub version: u32,
40    /// The specific event payload.
41    pub kind: RuntimeEventKind,
42}
43
44impl RuntimeEvent {
45    /// Creates a new event with version 1.
46    pub fn new(kind: RuntimeEventKind) -> Self {
47        Self { version: 1, kind }
48    }
49
50    /// Converts this event into an [`AgentEvent`] if the kind maps to one.
51    ///
52    /// Lifecycle-only events (session started/saved/finished, turn
53    /// started/completed, tool started, context updated) return `None` because
54    /// they have no direct agent-level counterpart.
55    pub fn into_agent_event(self) -> Option<AgentEvent> {
56        self.kind.into_agent_event()
57    }
58}
59
60/// Discriminates the kind of runtime event emitted by the agent loop.
61///
62/// Variants cover the full session lifecycle from start through turn
63/// execution, tool invocation, approval flow, compaction, and error reporting.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub enum RuntimeEventKind {
66    /// A new session has been created.
67    SessionStarted {
68        /// Unique identifier for the session.
69        session_id: String,
70    },
71    /// A new turn within the session has started.
72    TurnStarted {
73        /// Unique identifier for the turn.
74        turn_id: String,
75    },
76    /// A streaming text delta from the assistant.
77    AssistantDelta {
78        /// Incremental text content.
79        text: String,
80    },
81    /// A streaming thinking/reasoning delta from the assistant.
82    AssistantThinkingDelta {
83        /// Incremental thinking content.
84        text: String,
85    },
86    /// The assistant has requested a tool invocation.
87    ToolRequested(ToolInvocation),
88    /// A tool invocation requires user approval before execution.
89    ApprovalRequired(ApprovalRequest),
90    /// An approval request has been resolved (approved or denied).
91    ApprovalResolved(ApprovalDecision),
92    /// A capability lifecycle event was recorded by the policy layer.
93    CapabilityRecorded(CapabilityLedgerEntry),
94    /// The assistant has requested an interactive user choice.
95    QuestionRequired(QuestionRequest),
96    /// An interactive user choice has been resolved.
97    QuestionResolved(QuestionResponse),
98    /// Plan tool create is waiting for user review (blocks the turn).
99    PlanReviewRequired(PlanReviewRequest),
100    /// User finished plan review.
101    PlanReviewResolved(PlanReviewResponse),
102    /// Sudo password needed (no secret in event payload).
103    SudoPasswordRequired(SudoPasswordRequest),
104    /// A tool invocation has begun execution.
105    ToolStarted(ToolInvocation),
106    /// A tool invocation has completed.
107    ToolCompleted(ToolResult),
108    /// A nested subagent emitted a transient UI activity update.
109    SubagentActivity {
110        /// Parent subagent tool invocation id.
111        invocation_id: String,
112        /// Human-readable description of the latest nested activity.
113        message: String,
114    },
115    /// A nested subagent emitted a transient transcript item for UI drill-down.
116    SubagentTranscript {
117        /// Parent subagent tool invocation id.
118        invocation_id: String,
119        /// Transcript item to append for the active UI session.
120        item: SubagentTranscriptItem,
121    },
122    /// A harness-level diagnostic trace (profile, message count, tool count).
123    HarnessTrace(Value),
124    /// The harness stopped a turn before another model iteration.
125    HarnessStopped {
126        /// Machine-readable stop reason.
127        reason: String,
128        /// Human-readable diagnostic.
129        message: String,
130        /// Tool involved in the stop, when applicable.
131        #[serde(default, skip_serializing_if = "Option::is_none")]
132        tool_name: Option<String>,
133    },
134    /// A file patch has been proposed by the assistant.
135    PatchProposed(PatchProposal),
136    /// The conversation context has been updated.
137    ContextUpdated,
138    /// Token usage has been reported by the model provider.
139    TokensUpdated {
140        /// Number of input/prompt tokens consumed.
141        input_tokens: u64,
142        /// Number of output/completion tokens produced.
143        output_tokens: u64,
144        /// Number of tokens written to the prompt cache (Anthropic).
145        cache_creation_tokens: u64,
146        /// Number of tokens read from the prompt cache (Anthropic).
147        cache_read_tokens: u64,
148    },
149    /// The session has been persisted to disk.
150    SessionSaved {
151        /// Identifier of the saved session.
152        session_id: String,
153    },
154    /// Session display title was assigned or updated (provisional or model-named).
155    SessionTitleUpdated {
156        /// Identifier of the session.
157        session_id: String,
158        /// New display title.
159        title: String,
160    },
161    /// A turn has completed with a final text response.
162    TurnCompleted {
163        /// Identifier of the completed turn.
164        turn_id: String,
165        /// Final assistant text for the turn.
166        text: String,
167    },
168    /// The session has ended.
169    SessionFinished {
170        /// Identifier of the finished session.
171        session_id: String,
172    },
173    /// Micro-compaction cleared stale read-only tool results from history.
174    MicroCompactApplied {
175        /// Number of tool result messages that were cleared.
176        messages_cleared: usize,
177    },
178    /// An automatic conversation compaction has started.
179    AutoCompactStarted,
180    /// An automatic conversation compaction has completed.
181    AutoCompactCompleted {
182        /// Estimated number of tokens saved by compaction.
183        tokens_saved: u64,
184        /// Model-produced summary that replaced older turns.
185        #[serde(default)]
186        summary: String,
187        /// Non-system conversation messages retained after the summary.
188        #[serde(default)]
189        kept_recent_messages: usize,
190    },
191    /// An automatic conversation compaction has failed.
192    AutoCompactFailed {
193        /// Human-readable reason for the failure.
194        reason: String,
195    },
196    /// Auto-dream memory consolidation has started.
197    AutoDreamStarted {
198        /// Hours since the last dream run.
199        hours_since_last: u64,
200        /// Number of sessions reviewed.
201        sessions_reviewed: usize,
202    },
203    /// Auto-dream memory consolidation has completed.
204    AutoDreamCompleted {
205        /// Memories marked stale.
206        marked_stale: usize,
207        /// Duplicates merged.
208        duplicates_merged: usize,
209        /// Active memories remaining.
210        active_count: usize,
211    },
212    /// Auto-dream memory consolidation has failed.
213    AutoDreamFailed {
214        /// Human-readable reason for the failure.
215        reason: String,
216    },
217    /// The agent requested to set a goal via natural language.
218    SetGoalRequested {
219        /// The objective text.
220        objective: String,
221        /// Optional short UI label.
222        short_description: Option<String>,
223        /// Optional token budget.
224        token_budget: Option<i64>,
225    },
226    GoalUpdated {
227        /// The session this goal belongs to.
228        session_id: String,
229        /// Unique identifier for the goal.
230        goal_id: String,
231        /// The objective text.
232        objective: String,
233        /// Optional short UI label.
234        short_description: Option<String>,
235        /// Current goal status.
236        status: GoalStatus,
237        /// Tokens consumed so far.
238        tokens_used: i64,
239        /// Optional token budget.
240        token_budget: Option<i64>,
241    },
242    /// An error occurred during agent execution.
243    Error {
244        /// Human-readable error message.
245        message: String,
246    },
247    /// The agent proposed a plan in Plan mode.
248    /// The UI should show a confirmation popup to implement or discard.
249    PlanProposed {
250        /// The session that proposed the plan.
251        session_id: String,
252        /// Title/summary of the plan.
253        title: String,
254        /// Ordered list of steps.
255        steps: Vec<String>,
256    },
257    /// The agent mode changed (e.g. Default → Plan or Plan → Default).
258    AgentModeChanged {
259        /// The session whose mode changed.
260        session_id: String,
261        /// The new mode.
262        mode: crate::plan_mode::AgentMode,
263    },
264    /// Progress from an external ACP agent peer (not a model provider).
265    ///
266    /// Text deltas from ACP are also emitted as [`AssistantDelta`] when
267    /// applicable; this variant carries the full peer update for clients that
268    /// want tool/plan/permission detail from the external agent.
269    AcpPeerUpdate {
270        /// Configured ACP agent id (e.g. `"devin"`).
271        agent_id: String,
272        /// ACP session id on the external agent.
273        acp_session_id: String,
274        /// Discriminator for the update kind (`agent_message_chunk`, `tool_call`, …).
275        update_kind: String,
276        /// Full update payload as JSON.
277        update: Value,
278    },
279}
280
281impl RuntimeEventKind {
282    /// Converts this event kind into an [`AgentEvent`] if applicable.
283    ///
284    /// Returns `None` for lifecycle-only events that have no direct
285    /// agent-level counterpart (session/turn lifecycle, tool started,
286    /// context updated).
287    pub fn into_agent_event(self) -> Option<AgentEvent> {
288        match self {
289            RuntimeEventKind::AssistantDelta { text } => Some(AgentEvent::ModelDelta { text }),
290            RuntimeEventKind::AssistantThinkingDelta { text } => {
291                Some(AgentEvent::ModelThinkingDelta { text })
292            }
293            RuntimeEventKind::ToolRequested(invocation) => {
294                Some(AgentEvent::ToolRequested(invocation))
295            }
296            RuntimeEventKind::ApprovalRequired(request) => {
297                Some(AgentEvent::ApprovalRequested(request))
298            }
299            RuntimeEventKind::ApprovalResolved(decision) => {
300                Some(AgentEvent::ApprovalResolved(decision))
301            }
302            RuntimeEventKind::CapabilityRecorded(entry) => {
303                Some(AgentEvent::CapabilityRecorded(entry))
304            }
305            RuntimeEventKind::QuestionRequired(request) => {
306                Some(AgentEvent::QuestionRequested(request))
307            }
308            RuntimeEventKind::QuestionResolved(response) => {
309                Some(AgentEvent::QuestionResolved(response))
310            }
311            RuntimeEventKind::PlanReviewRequired(request) => {
312                Some(AgentEvent::PlanReviewRequested(request))
313            }
314            RuntimeEventKind::PlanReviewResolved(response) => {
315                Some(AgentEvent::PlanReviewResolved(response))
316            }
317            RuntimeEventKind::SudoPasswordRequired(request) => {
318                Some(AgentEvent::SudoPasswordRequested(request))
319            }
320            RuntimeEventKind::ToolCompleted(result) => Some(AgentEvent::ToolCompleted(result)),
321            RuntimeEventKind::SubagentActivity {
322                invocation_id,
323                message,
324            } => Some(AgentEvent::SubagentActivity {
325                invocation_id,
326                message,
327            }),
328            RuntimeEventKind::SubagentTranscript {
329                invocation_id,
330                item,
331            } => Some(AgentEvent::SubagentTranscript {
332                invocation_id,
333                item,
334            }),
335            RuntimeEventKind::HarnessTrace(value) => Some(AgentEvent::HarnessTrace(value)),
336            RuntimeEventKind::HarnessStopped {
337                reason,
338                message,
339                tool_name,
340            } => Some(AgentEvent::HarnessStopped {
341                reason,
342                message,
343                tool_name,
344            }),
345            RuntimeEventKind::PatchProposed(patch) => Some(AgentEvent::PatchProposed(patch)),
346            RuntimeEventKind::TokensUpdated {
347                input_tokens,
348                output_tokens,
349                cache_creation_tokens,
350                cache_read_tokens,
351            } => Some(AgentEvent::UsageReported {
352                input_tokens,
353                output_tokens,
354                cache_creation_tokens,
355                cache_read_tokens,
356            }),
357            RuntimeEventKind::MicroCompactApplied { messages_cleared } => {
358                Some(AgentEvent::MicroCompactApplied { messages_cleared })
359            }
360            RuntimeEventKind::AutoCompactStarted => Some(AgentEvent::AutoCompactStarted),
361            RuntimeEventKind::AutoCompactCompleted {
362                tokens_saved,
363                summary,
364                kept_recent_messages,
365            } => Some(AgentEvent::AutoCompactCompleted {
366                tokens_saved,
367                summary,
368                kept_recent_messages,
369            }),
370            RuntimeEventKind::AutoCompactFailed { reason } => {
371                Some(AgentEvent::AutoCompactFailed { reason })
372            }
373            RuntimeEventKind::Error { message } => Some(AgentEvent::Error { message }),
374            RuntimeEventKind::SetGoalRequested {
375                objective,
376                short_description,
377                token_budget,
378            } => Some(AgentEvent::SetGoalRequested {
379                objective,
380                short_description,
381                token_budget,
382            }),
383            RuntimeEventKind::GoalUpdated {
384                session_id,
385                goal_id,
386                objective,
387                short_description,
388                status,
389                tokens_used,
390                token_budget,
391            } => Some(AgentEvent::GoalUpdated {
392                session_id,
393                goal_id,
394                objective,
395                short_description,
396                status,
397                tokens_used,
398                token_budget,
399            }),
400            RuntimeEventKind::PlanProposed { title, steps, .. } => {
401                Some(AgentEvent::PlanProposed { title, steps })
402            }
403            RuntimeEventKind::AgentModeChanged { mode, .. } => {
404                Some(AgentEvent::AgentModeChanged { mode })
405            }
406            _ => None,
407        }
408    }
409}
410
411/// A high-level agent event suitable for client consumption.
412///
413/// Unlike [`RuntimeEventKind`], agent events represent the semantic actions
414/// a client cares about: user input, model output, tool calls, approvals,
415/// compaction, usage, and errors.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub enum AgentEvent {
418    /// The user submitted a new task or message.
419    UserTaskSubmitted {
420        /// The user's input text.
421        text: String,
422        /// Optional multimodal content parts (images + text).
423        #[serde(default, skip_serializing_if = "Vec::is_empty")]
424        content_parts: Vec<crate::model::ContentPart>,
425        /// Unix timestamp (seconds since epoch) when the user submitted this
426        /// message. Used for wall-clock display in clients (e.g. TUI sticky bar).
427        /// Optional for backward-compatible session JSON (pre-timestamp sessions).
428        #[serde(default, skip_serializing_if = "Option::is_none")]
429        submitted_at: Option<u64>,
430    },
431    /// A complete model output with optional thinking/reasoning content.
432    ModelOutput {
433        /// The assistant's response text.
434        text: String,
435        /// Optional thinking or reasoning trace from the model.
436        #[serde(default)]
437        thinking: Option<String>,
438    },
439    /// A streaming text delta from the model.
440    ModelDelta {
441        /// Incremental text content.
442        text: String,
443    },
444    /// A streaming thinking/reasoning delta from the model.
445    ModelThinkingDelta {
446        /// Incremental thinking content.
447        text: String,
448    },
449    /// Live-only: the model is streaming tool-call arguments before
450    /// [`ToolRequested`]. Not persisted in the session event log.
451    ToolCallStreaming {
452        /// Provider tool-call id when known.
453        #[serde(default, skip_serializing_if = "Option::is_none")]
454        id: Option<String>,
455        /// Tool name when known.
456        tool_name: String,
457        /// Total characters of arguments streamed so far for this call.
458        #[serde(default)]
459        arguments_chars: usize,
460    },
461    /// The assistant requested a tool invocation.
462    ToolRequested(ToolInvocation),
463    /// A tool invocation completed.
464    ToolCompleted(ToolResult),
465    /// Transient status for a nested subagent.
466    SubagentActivity {
467        /// Parent subagent tool invocation id.
468        invocation_id: String,
469        /// Human-readable description of the latest nested activity.
470        message: String,
471    },
472    /// Transient drill-down transcript item for a nested subagent.
473    SubagentTranscript {
474        /// Parent subagent tool invocation id.
475        invocation_id: String,
476        /// Transcript item to append for this UI session.
477        item: SubagentTranscriptItem,
478    },
479    /// A harness-level diagnostic trace.
480    HarnessTrace(Value),
481    /// The harness stopped a turn before another model iteration.
482    HarnessStopped {
483        /// Machine-readable stop reason.
484        reason: String,
485        /// Human-readable diagnostic.
486        message: String,
487        /// Tool involved in the stop, when applicable.
488        #[serde(default, skip_serializing_if = "Option::is_none")]
489        tool_name: Option<String>,
490    },
491    /// A file patch was proposed by the assistant.
492    PatchProposed(PatchProposal),
493    /// A tool invocation requires user approval.
494    ApprovalRequested(ApprovalRequest),
495    /// An approval request was resolved.
496    ApprovalResolved(ApprovalDecision),
497    /// A capability lifecycle event was recorded by the policy layer.
498    CapabilityRecorded(CapabilityLedgerEntry),
499    /// The assistant requested an interactive user choice.
500    QuestionRequested(QuestionRequest),
501    /// An interactive user choice was resolved.
502    QuestionResolved(QuestionResponse),
503    /// Plan tool create is waiting for user review (blocks the turn).
504    PlanReviewRequested(PlanReviewRequest),
505    /// User finished plan review (approve / changes / quit).
506    PlanReviewResolved(PlanReviewResponse),
507    /// Bash/`sudo` needs a password; TUI shows a masked modal. Password is
508    /// never included in this event — only a correlation id. The secret is
509    /// delivered solely through the sudo password resolver oneshot.
510    SudoPasswordRequested(SudoPasswordRequest),
511    /// The same tool was called consecutively with identical arguments.
512    /// The tool is NOT executed; this is a notification to the user.
513    RepeatedToolCallWarning {
514        /// Name of the repeated tool.
515        tool_name: String,
516        /// Warning message describing the repetition.
517        message: String,
518    },
519    /// Repetitive/degenerate model output was detected (character runs,
520    /// alternating patterns, or duplicate thinking blocks).
521    RepetitionDetected {
522        /// What kind of repetition was detected.
523        kind: RepetitionWarningKind,
524        /// Human-readable warning message.
525        message: String,
526    },
527    /// An error occurred.
528    Error {
529        /// Human-readable error message.
530        message: String,
531    },
532    /// Auto-dream memory consolidation has started.
533    AutoDreamStarted {
534        /// Hours since the last dream run.
535        hours_since_last: u64,
536        /// Number of sessions reviewed.
537        sessions_reviewed: usize,
538    },
539    /// Auto-dream memory consolidation has completed.
540    AutoDreamCompleted {
541        /// Memories marked stale.
542        marked_stale: usize,
543        /// Duplicates merged.
544        duplicates_merged: usize,
545        /// Active memories remaining.
546        active_count: usize,
547    },
548    /// Auto-dream memory consolidation has failed.
549    AutoDreamFailed {
550        /// Human-readable reason for the failure.
551        reason: String,
552    },
553    /// Token usage was reported by the model provider.
554    UsageReported {
555        /// Number of input/prompt tokens consumed.
556        input_tokens: u64,
557        /// Number of output/completion tokens produced.
558        output_tokens: u64,
559        /// Number of tokens written to the prompt cache (Anthropic).
560        cache_creation_tokens: u64,
561        /// Number of tokens read from the prompt cache (Anthropic).
562        cache_read_tokens: u64,
563    },
564    /// Short post-turn session recap ("Recap" line).
565    SessionRecap {
566        /// One- or two-sentence summary of the turn.
567        summary: String,
568        /// When true, the recap was generated but should not be shown (long-tail).
569        suppressed: bool,
570    },
571    /// The provider stream broke mid-generation and is being resumed via
572    /// prefill (assistant continuation). The UI can show a transient hint.
573    StreamResuming {
574        /// Characters of text accumulated before the break.
575        accumulated_chars: usize,
576        /// Retry attempt number (1-based).
577        attempt: u32,
578    },
579    /// The agent requested to set a goal via natural language.
580    SetGoalRequested {
581        /// The objective text.
582        objective: String,
583        /// Optional short UI label.
584        short_description: Option<String>,
585        /// Optional token budget.
586        token_budget: Option<i64>,
587    },
588    /// The session goal was updated (created, status change, budget exceeded).
589    GoalUpdated {
590        /// The session this goal belongs to.
591        session_id: String,
592        /// Unique identifier for the goal.
593        goal_id: String,
594        /// The objective text.
595        objective: String,
596        /// Optional short UI label.
597        short_description: Option<String>,
598        /// Current goal status.
599        status: GoalStatus,
600        /// Tokens consumed so far.
601        tokens_used: i64,
602        /// Optional token budget.
603        token_budget: Option<i64>,
604    },
605    /// Micro-compaction cleared stale tool results from history.
606    MicroCompactApplied {
607        /// Number of tool result messages cleared.
608        messages_cleared: usize,
609    },
610    /// Automatic conversation compaction started.
611    AutoCompactStarted,
612    /// Automatic conversation compaction completed.
613    AutoCompactCompleted {
614        /// Estimated tokens saved by compaction.
615        tokens_saved: u64,
616        /// Model-produced summary that replaced older turns.
617        #[serde(default)]
618        summary: String,
619        /// Non-system conversation messages retained after the summary.
620        #[serde(default)]
621        kept_recent_messages: usize,
622    },
623    /// Automatic conversation compaction failed.
624    AutoCompactFailed {
625        /// Human-readable failure reason.
626        reason: String,
627    },
628    /// The agent proposed a plan in Plan mode.
629    PlanProposed {
630        /// Title/summary of the plan.
631        title: String,
632        /// Ordered list of steps.
633        steps: Vec<String>,
634    },
635    /// The agent mode changed (e.g. Default → Plan or Plan → Default).
636    AgentModeChanged {
637        /// The new mode.
638        mode: crate::plan_mode::AgentMode,
639    },
640    /// Host should surface a user-visible notification.
641    ///
642    /// Desktop hosts call OS toasts; browser hosts map this to the Web
643    /// Notifications API. Payload mirrors [`crate::notify::NotifyRequest`].
644    NotificationRequested {
645        title: String,
646        body: String,
647        #[serde(default)]
648        urgency: crate::notify::NotificationUrgency,
649        #[serde(default, skip_serializing_if = "Option::is_none")]
650        category: Option<String>,
651    },
652    /// A newer NAVI release is available (from update check).
653    UpdateAvailable {
654        current_version: String,
655        latest_version: String,
656        latest_tag: String,
657        release_url: String,
658        #[serde(default, skip_serializing_if = "Option::is_none")]
659        body: Option<String>,
660        #[serde(default)]
661        prerelease: bool,
662    },
663}
664
665/// Kind of repetitive/degenerate output detected by the repetition detector.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(tag = "type")]
668pub enum RepetitionWarningKind {
669    /// Same character repeated many times (e.g. "aaaaaa...").
670    CharRun {
671        /// The repeating character.
672        ch: char,
673        /// How many consecutive occurrences.
674        count: usize,
675    },
676    /// Two characters alternating many times (e.g. "-_-_-_").
677    AlternatingPattern {
678        /// The two-character pattern (e.g. "-_").
679        pattern: String,
680        /// How many cycles detected.
681        cycles: usize,
682    },
683}
684
685/// A pending approval request for a tool invocation that requires user consent.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct ApprovalRequest {
688    /// Unique identifier for this approval request.
689    pub id: String,
690    /// Human-readable summary of what the tool will do.
691    pub summary: String,
692    /// The security risk category that triggered the approval requirement.
693    pub risk: ApprovalRisk,
694}
695
696/// A selectable option in a [`QuestionRequest`].
697#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
698pub struct QuestionOption {
699    /// Short option label shown in the selection UI and returned to the model.
700    pub label: String,
701    /// Optional explanatory text shown below the label.
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub description: Option<String>,
704}
705
706/// A pending interactive question requested by the assistant through the
707/// `question` tool.
708#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
709pub struct QuestionRequest {
710    /// Unique identifier matching the tool invocation id.
711    pub id: String,
712    /// Prompt shown to the user.
713    pub question: String,
714    /// Selectable options.
715    #[serde(default)]
716    pub options: Vec<QuestionOption>,
717    /// Whether more than one option may be selected.
718    #[serde(default)]
719    pub multiple: bool,
720    /// Whether the UI should allow a free-form custom answer.
721    #[serde(default)]
722    pub allow_custom: bool,
723}
724
725/// Resolution for an interactive [`QuestionRequest`].
726#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
727#[serde(tag = "kind", rename_all = "snake_case")]
728pub enum QuestionResponse {
729    /// The user selected one or more answers.
730    Answered {
731        /// Question/tool invocation id.
732        id: String,
733        /// Selected labels or the custom answer text.
734        answers: Vec<String>,
735    },
736    /// The user dismissed the question without answering.
737    Dismissed {
738        /// Question/tool invocation id.
739        id: String,
740    },
741}
742
743impl QuestionResponse {
744    /// Returns the request id this response resolves.
745    pub fn id(&self) -> &str {
746        match self {
747            Self::Answered { id, .. } | Self::Dismissed { id } => id,
748        }
749    }
750}
751
752/// Interactive plan review requested after `plan(action=submit|create)`.
753/// The turn **blocks** until the user resolves it.
754#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
755pub struct PlanReviewRequest {
756    /// Tool invocation id (used as correlation key for the oneshot).
757    pub id: String,
758    /// Persisted plan id in SQLite.
759    pub plan_id: String,
760    pub title: String,
761    pub description: String,
762    pub steps: Vec<String>,
763    /// Full markdown design doc (primary content for review UI).
764    #[serde(default)]
765    pub body_markdown: String,
766    /// On-disk plan markdown file path.
767    #[serde(default)]
768    pub plan_file_path: String,
769}
770
771/// User decision from the plan review modal.
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(rename_all = "snake_case")]
774pub enum PlanReviewDecision {
775    Approve,
776    RequestChanges,
777    Quit,
778}
779
780/// Resolution for a blocked plan review.
781#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
782pub struct PlanReviewResponse {
783    /// Matching tool invocation id.
784    pub id: String,
785    pub plan_id: String,
786    pub decision: PlanReviewDecision,
787    /// Line-oriented comments from the modal.
788    #[serde(default)]
789    pub comments: Vec<crate::plan_store::PlanLineComment>,
790    /// Freeform notes from the prompt field.
791    #[serde(default)]
792    pub freeform: String,
793}
794
795impl PlanReviewResponse {
796    pub fn id(&self) -> &str {
797        &self.id
798    }
799}
800
801/// Request for a sudo password from the interactive TUI.
802///
803/// **Security:** this event must never carry the password itself — only a
804/// correlation id and UI context (command summary). The secret is delivered
805/// solely through [`crate::runtime::SudoPasswordResolver`].
806#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
807pub struct SudoPasswordRequest {
808    /// Correlation id for the oneshot resolver.
809    pub id: String,
810    /// Short, non-secret description (e.g. truncated command).
811    pub command_summary: String,
812}
813
814/// Resolution of a sudo password prompt.
815///
816/// Prefer not to serialize responses that still hold a password into logs.
817#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(tag = "kind", rename_all = "snake_case")]
819pub enum SudoPasswordResponse {
820    /// User entered a password. Cleared from memory after bash consumes it.
821    Submitted {
822        id: String,
823        /// Secret — never write to chat history or tool observations.
824        #[serde(skip_serializing)]
825        password: String,
826    },
827    /// User cancelled the modal.
828    Cancelled { id: String },
829}
830
831impl SudoPasswordResponse {
832    pub fn id(&self) -> &str {
833        match self {
834            Self::Submitted { id, .. } | Self::Cancelled { id } => id,
835        }
836    }
837}
838
839/// The security risk category associated with an approval request.
840#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
841pub enum ApprovalRisk {
842    /// Any tool execution in restricted mode.
843    Tool,
844    /// A file write operation.
845    Write,
846    /// A shell command execution.
847    Command,
848    /// A guarded command that requires explicit approval outside YOLO mode.
849    Guarded,
850    /// Loading or executing an external plugin.
851    ExternalPlugin,
852}
853
854/// The outcome of an approval request.
855#[derive(Debug, Clone, Serialize, Deserialize)]
856pub enum ApprovalDecision {
857    /// The user approved the action.
858    Approved {
859        /// Identifier matching the [`ApprovalRequest::id`].
860        id: String,
861    },
862    /// The user denied the action.
863    Denied {
864        /// Identifier matching the [`ApprovalRequest::id`].
865        id: String,
866    },
867}