Skip to main content

objectiveai_cli/db/logs/
read_conversation.rs

1//! Content-inlined conversation snapshot backing the daemon's
2//! `/agents/instances/{*aih}` route.
3//!
4//! The same metadata query as [`super::read_all`] (shared
5//! `SELECT_SHAPE` / `FROM_JOINS` / [`MsgRow`]), but instead of
6//! emitting `{id, type}` parts for `agents logs read id` to resolve,
7//! each row's ACTUAL content is batch-fetched from its per-kind table
8//! and inlined into a typed SDK
9//! [`AgentInstanceEvent`](objectiveai_sdk::cli::websocket_agents_instances_listener::AgentInstanceEvent)
10//! — the same frame shape the live tee ships, so the WS handler
11//! replays the snapshot and relays live frames through one type and
12//! clients converge the seam by part identity.
13//!
14//! Batching: one metadata page (`"index"` ASC), then ONE query per
15//! content family present in the page (`unnest` key joins /
16//! `id = ANY` for the message-queue and error kinds) — never a
17//! per-row round trip. Request-blob rows are skipped (hidden from
18//! conversations, exactly as `read_all` hides them); a metadata row
19//! whose content row is missing (torn write) skips that row.
20
21use std::collections::HashMap;
22
23use objectiveai_sdk::agent::completions::message::{File, ImageUrl, InputAudio, VideoUrl};
24use objectiveai_sdk::cli::websocket_agents_instances_listener::{
25    AgentInstanceEvent, AssistantResponsePart, ClientNotificationPart, PartContent,
26    RequestMessageUserPart, ToolResponsePart, VectorRequestChoicePart,
27};
28use sqlx::Row as _;
29
30use super::super::time::unix_to_rfc3339;
31use super::super::{Error, Pool};
32use super::read_all::{FROM_JOINS, MsgRow, SELECT_SHAPE, row_into_msg};
33use super::row::MessageTable;
34
35/// Which media columns a content table carries.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37enum ContentKind {
38    Text,
39    Image,
40    Audio,
41    Video,
42    File,
43}
44
45/// Where one metadata row's content lives.
46enum Source {
47    /// `(response_id, "index", part_index)`-keyed content-part table.
48    Part { table: &'static str, kind: ContentKind },
49    /// `(response_id, "index")`-keyed single-text table
50    /// (refusal / reasoning).
51    IndexedText { table: &'static str },
52    /// `(response_id, "index", tool_call_index)`-keyed tool-call table
53    /// — arguments + call metadata.
54    ToolCalls { table: &'static str },
55    /// `id`-keyed per-kind message-queue content table
56    /// (`row_index` = `message_queue_contents.id`).
57    MessageQueue { table: &'static str, kind: ContentKind },
58    /// `id`-keyed `objectiveai.errors` row (`row_index` = `errors.id`).
59    ErrorRow,
60    /// Inline on the metadata row (`MsgRow::vote`).
61    Vote,
62    /// Request-blob marker — hidden from conversations.
63    Blob,
64}
65
66/// Content source per `objectiveai.message_table` kind. Table/column
67/// names mirror [`super::read_id`]'s per-kind loaders exactly.
68fn source(t: MessageTable) -> Source {
69    use ContentKind as K;
70    use MessageTable as T;
71    match t {
72        T::AgentCompletionRequest
73        | T::VectorCompletionRequest
74        | T::FunctionExecutionRequest => Source::Blob,
75        T::MessageQueueText => Source::MessageQueue {
76            table: "objectiveai.message_queue_texts",
77            kind: K::Text,
78        },
79        T::MessageQueueImage => Source::MessageQueue {
80            table: "objectiveai.message_queue_images",
81            kind: K::Image,
82        },
83        T::MessageQueueAudio => Source::MessageQueue {
84            table: "objectiveai.message_queue_audios",
85            kind: K::Audio,
86        },
87        T::MessageQueueVideo => Source::MessageQueue {
88            table: "objectiveai.message_queue_videos",
89            kind: K::Video,
90        },
91        T::MessageQueueFile => Source::MessageQueue {
92            table: "objectiveai.message_queue_files",
93            kind: K::File,
94        },
95        T::AssistantResponseRefusal => Source::IndexedText {
96            table: "objectiveai.assistant_response_refusal",
97        },
98        T::AssistantResponseReasoning => Source::IndexedText {
99            table: "objectiveai.assistant_response_reasoning",
100        },
101        T::AssistantResponseToolCalls => Source::ToolCalls {
102            table: "objectiveai.assistant_response_tool_calls",
103        },
104        T::AssistantResponseContentText => Source::Part {
105            table: "objectiveai.assistant_response_content_text",
106            kind: K::Text,
107        },
108        T::AssistantResponseContentImage => Source::Part {
109            table: "objectiveai.assistant_response_content_image",
110            kind: K::Image,
111        },
112        T::AssistantResponseContentAudio => Source::Part {
113            table: "objectiveai.assistant_response_content_audio",
114            kind: K::Audio,
115        },
116        T::AssistantResponseContentVideo => Source::Part {
117            table: "objectiveai.assistant_response_content_video",
118            kind: K::Video,
119        },
120        T::AssistantResponseContentFile => Source::Part {
121            table: "objectiveai.assistant_response_content_file",
122            kind: K::File,
123        },
124        T::ToolResponseContentText => Source::Part {
125            table: "objectiveai.tool_response_content_text",
126            kind: K::Text,
127        },
128        T::ToolResponseContentImage => Source::Part {
129            table: "objectiveai.tool_response_content_image",
130            kind: K::Image,
131        },
132        T::ToolResponseContentAudio => Source::Part {
133            table: "objectiveai.tool_response_content_audio",
134            kind: K::Audio,
135        },
136        T::ToolResponseContentVideo => Source::Part {
137            table: "objectiveai.tool_response_content_video",
138            kind: K::Video,
139        },
140        T::ToolResponseContentFile => Source::Part {
141            table: "objectiveai.tool_response_content_file",
142            kind: K::File,
143        },
144        T::RequestMessageUserContentText => Source::Part {
145            table: "objectiveai.request_message_user_content_text",
146            kind: K::Text,
147        },
148        T::RequestMessageUserContentImage => Source::Part {
149            table: "objectiveai.request_message_user_content_image",
150            kind: K::Image,
151        },
152        T::RequestMessageUserContentAudio => Source::Part {
153            table: "objectiveai.request_message_user_content_audio",
154            kind: K::Audio,
155        },
156        T::RequestMessageUserContentVideo => Source::Part {
157            table: "objectiveai.request_message_user_content_video",
158            kind: K::Video,
159        },
160        T::RequestMessageUserContentFile => Source::Part {
161            table: "objectiveai.request_message_user_content_file",
162            kind: K::File,
163        },
164        T::RequestMessageAssistantRefusal => Source::IndexedText {
165            table: "objectiveai.request_message_assistant_refusal",
166        },
167        T::RequestMessageAssistantReasoning => Source::IndexedText {
168            table: "objectiveai.request_message_assistant_reasoning",
169        },
170        T::RequestMessageAssistantToolCalls => Source::ToolCalls {
171            table: "objectiveai.request_message_assistant_tool_calls",
172        },
173        T::RequestMessageAssistantContentText => Source::Part {
174            table: "objectiveai.request_message_assistant_content_text",
175            kind: K::Text,
176        },
177        T::RequestMessageAssistantContentImage => Source::Part {
178            table: "objectiveai.request_message_assistant_content_image",
179            kind: K::Image,
180        },
181        T::RequestMessageAssistantContentAudio => Source::Part {
182            table: "objectiveai.request_message_assistant_content_audio",
183            kind: K::Audio,
184        },
185        T::RequestMessageAssistantContentVideo => Source::Part {
186            table: "objectiveai.request_message_assistant_content_video",
187            kind: K::Video,
188        },
189        T::RequestMessageAssistantContentFile => Source::Part {
190            table: "objectiveai.request_message_assistant_content_file",
191            kind: K::File,
192        },
193        T::RequestMessageToolContentText => Source::Part {
194            table: "objectiveai.request_message_tool_content_text",
195            kind: K::Text,
196        },
197        T::RequestMessageToolContentImage => Source::Part {
198            table: "objectiveai.request_message_tool_content_image",
199            kind: K::Image,
200        },
201        T::RequestMessageToolContentAudio => Source::Part {
202            table: "objectiveai.request_message_tool_content_audio",
203            kind: K::Audio,
204        },
205        T::RequestMessageToolContentVideo => Source::Part {
206            table: "objectiveai.request_message_tool_content_video",
207            kind: K::Video,
208        },
209        T::RequestMessageToolContentFile => Source::Part {
210            table: "objectiveai.request_message_tool_content_file",
211            kind: K::File,
212        },
213        T::RequestVectorChoiceContentText => Source::Part {
214            table: "objectiveai.request_vector_choice_content_text",
215            kind: K::Text,
216        },
217        T::RequestVectorChoiceContentImage => Source::Part {
218            table: "objectiveai.request_vector_choice_content_image",
219            kind: K::Image,
220        },
221        T::RequestVectorChoiceContentAudio => Source::Part {
222            table: "objectiveai.request_vector_choice_content_audio",
223            kind: K::Audio,
224        },
225        T::RequestVectorChoiceContentVideo => Source::Part {
226            table: "objectiveai.request_vector_choice_content_video",
227            kind: K::Video,
228        },
229        T::RequestVectorChoiceContentFile => Source::Part {
230            table: "objectiveai.request_vector_choice_content_file",
231            kind: K::File,
232        },
233        T::ResponseVectorVote => Source::Vote,
234        T::Error => Source::ErrorRow,
235    }
236}
237
238/// One fetched content value, keyed by the metadata row's
239/// `(table, response_id, row_index, row_sub_index)`.
240enum Fetched {
241    /// A media content part.
242    Media(PartContent),
243    /// A refusal / reasoning text.
244    Text(String),
245    /// A tool call's full state.
246    ToolCall {
247        tool_call_id: String,
248        function_name: String,
249        arguments: String,
250    },
251}
252
253type ContentMap = HashMap<(MessageTable, String, i64, Option<i64>), Fetched>;
254
255/// Build the per-kind media content from one fetched content row.
256/// Column shapes are identical across every table of a kind — the
257/// same contract [`super::read_id`] relies on.
258fn media_content(kind: ContentKind, row: &sqlx::postgres::PgRow) -> Result<PartContent, Error> {
259    Ok(match kind {
260        ContentKind::Text => PartContent::Text {
261            text: row.try_get("text")?,
262        },
263        ContentKind::Image => {
264            let url: String = row.try_get("url")?;
265            let detail_str: Option<String> = row.try_get("detail")?;
266            let detail = match detail_str {
267                Some(s) => serde_json::from_value(serde_json::Value::String(s))?,
268                None => None,
269            };
270            PartContent::Image(ImageUrl { url, detail })
271        }
272        ContentKind::Audio => PartContent::Audio(InputAudio {
273            data: row.try_get("data")?,
274            format: row.try_get("format")?,
275        }),
276        ContentKind::Video => PartContent::Video(VideoUrl {
277            url: row.try_get("url")?,
278        }),
279        ContentKind::File => PartContent::File(File {
280            file_data: row.try_get("file_data")?,
281            file_id: row.try_get("file_id")?,
282            filename: row.try_get("filename")?,
283            file_url: row.try_get("file_url")?,
284        }),
285    })
286}
287
288/// The columns to select for a media kind.
289fn media_columns(kind: ContentKind) -> &'static str {
290    match kind {
291        ContentKind::Text => "text",
292        ContentKind::Image => "url, detail",
293        ContentKind::Audio => "data, format",
294        ContentKind::Video => "url",
295        ContentKind::File => "file_data, file_id, filename, file_url",
296    }
297}
298
299/// An assistant-part from its media content.
300fn assistant_media(delivered_at: String, content: PartContent) -> AssistantResponsePart {
301    match content {
302        PartContent::Text { text } => AssistantResponsePart::Text { delivered_at, text },
303        PartContent::Image(image) => AssistantResponsePart::Image {
304            delivered_at,
305            image,
306        },
307        PartContent::Audio(audio) => AssistantResponsePart::Audio {
308            delivered_at,
309            audio,
310        },
311        PartContent::Video(video) => AssistantResponsePart::Video {
312            delivered_at,
313            video,
314        },
315        PartContent::File(file) => AssistantResponsePart::File { delivered_at, file },
316    }
317}
318
319/// One page of an agent's conversation as typed events, content
320/// inlined, in `objectiveai.messages."index"` order. Returns the
321/// events plus the `after_id` cursor for the next page (`None` when
322/// this page was the last). The caller (the daemon WS handler) loops
323/// pages, streaming each event as one frame — bounded memory for huge
324/// histories.
325pub async fn read_conversation_page(
326    pool: &Pool,
327    agent_instance_hierarchy: &str,
328    after_id: Option<i64>,
329    limit: i64,
330) -> Result<(Vec<AgentInstanceEvent>, Option<i64>), Error> {
331    // Phase 1 — one metadata page via the shared read_all query.
332    let sql = format!(
333        "{SELECT_SHAPE} {FROM_JOINS} \
334         WHERE m.agent_instance_hierarchy = $1 AND m.\"index\" > $2 \
335         ORDER BY m.\"index\" ASC \
336         LIMIT $3",
337    );
338    let rows = sqlx::query(&sql)
339        .bind(agent_instance_hierarchy)
340        .bind(after_id.unwrap_or(0))
341        .bind(limit)
342        .fetch_all(&**pool)
343        .await?;
344    let msgs = rows
345        .iter()
346        .map(row_into_msg)
347        .collect::<Result<Vec<MsgRow>, Error>>()?;
348    let next_after_id = if msgs.len() as i64 == limit {
349        msgs.last().map(|m| m.id)
350    } else {
351        None
352    };
353
354    // Phase 2 — group the page's rows by content source and batch-fetch
355    // each group in one query.
356    let mut part_keys: HashMap<MessageTable, (Vec<String>, Vec<i64>, Vec<i64>)> = HashMap::new();
357    let mut text_keys: HashMap<MessageTable, (Vec<String>, Vec<i64>)> = HashMap::new();
358    let mut call_keys: HashMap<MessageTable, (Vec<String>, Vec<i64>, Vec<i64>)> = HashMap::new();
359    let mut queue_ids: HashMap<MessageTable, Vec<i64>> = HashMap::new();
360    let mut error_ids: Vec<i64> = Vec::new();
361    for msg in &msgs {
362        // Every content-fetch kind carries a response_id (only
363        // `error` rows may lack one, and those fetch by id alone).
364        let rid = msg.response_id.clone().unwrap_or_default();
365        match source(msg.table_kind) {
366            Source::Part { .. } => {
367                let (Some(index), Some(part_index)) = (msg.row_index, msg.row_sub_index) else {
368                    continue;
369                };
370                let entry = part_keys.entry(msg.table_kind).or_default();
371                entry.0.push(rid);
372                entry.1.push(index);
373                entry.2.push(part_index);
374            }
375            Source::IndexedText { .. } => {
376                let Some(index) = msg.row_index else { continue };
377                let entry = text_keys.entry(msg.table_kind).or_default();
378                entry.0.push(rid);
379                entry.1.push(index);
380            }
381            Source::ToolCalls { .. } => {
382                let (Some(index), Some(tool_call_index)) = (msg.row_index, msg.row_sub_index)
383                else {
384                    continue;
385                };
386                let entry = call_keys.entry(msg.table_kind).or_default();
387                entry.0.push(rid);
388                entry.1.push(index);
389                entry.2.push(tool_call_index);
390            }
391            Source::MessageQueue { .. } => {
392                let Some(id) = msg.row_index else { continue };
393                queue_ids.entry(msg.table_kind).or_default().push(id);
394            }
395            Source::ErrorRow => {
396                let Some(id) = msg.row_index else { continue };
397                error_ids.push(id);
398            }
399            Source::Vote | Source::Blob => {}
400        }
401    }
402
403    let mut content: ContentMap = HashMap::new();
404    let mut errors_by_id: HashMap<i64, serde_json::Value> = HashMap::new();
405
406    for (kind_table, (rids, indices, part_indices)) in &part_keys {
407        let Source::Part { table, kind } = source(*kind_table) else {
408            unreachable!("grouped as Part above");
409        };
410        let sql = format!(
411            "SELECT t.response_id, t.\"index\" AS idx, t.part_index AS pidx, {} \
412             FROM {table} t \
413             JOIN unnest($1::text[], $2::bigint[], $3::bigint[]) AS k(rid, idx, pidx) \
414               ON t.response_id = k.rid AND t.\"index\" = k.idx AND t.part_index = k.pidx",
415            media_columns(kind),
416        );
417        for row in sqlx::query(&sql)
418            .bind(rids)
419            .bind(indices)
420            .bind(part_indices)
421            .fetch_all(&**pool)
422            .await?
423        {
424            let rid: String = row.try_get("response_id")?;
425            let idx: i64 = row.try_get("idx")?;
426            let pidx: i64 = row.try_get("pidx")?;
427            content.insert(
428                (*kind_table, rid, idx, Some(pidx)),
429                Fetched::Media(media_content(kind, &row)?),
430            );
431        }
432    }
433
434    for (kind_table, (rids, indices)) in &text_keys {
435        let Source::IndexedText { table } = source(*kind_table) else {
436            unreachable!("grouped as IndexedText above");
437        };
438        let sql = format!(
439            "SELECT t.response_id, t.\"index\" AS idx, t.text \
440             FROM {table} t \
441             JOIN unnest($1::text[], $2::bigint[]) AS k(rid, idx) \
442               ON t.response_id = k.rid AND t.\"index\" = k.idx",
443        );
444        for row in sqlx::query(&sql)
445            .bind(rids)
446            .bind(indices)
447            .fetch_all(&**pool)
448            .await?
449        {
450            let rid: String = row.try_get("response_id")?;
451            let idx: i64 = row.try_get("idx")?;
452            let text: String = row.try_get("text")?;
453            content.insert((*kind_table, rid, idx, None), Fetched::Text(text));
454        }
455    }
456
457    for (kind_table, (rids, indices, call_indices)) in &call_keys {
458        let Source::ToolCalls { table } = source(*kind_table) else {
459            unreachable!("grouped as ToolCalls above");
460        };
461        let sql = format!(
462            "SELECT t.response_id, t.\"index\" AS idx, t.tool_call_index AS tci, \
463                    t.tool_call_id, t.function_name, t.arguments \
464             FROM {table} t \
465             JOIN unnest($1::text[], $2::bigint[], $3::bigint[]) AS k(rid, idx, tci) \
466               ON t.response_id = k.rid AND t.\"index\" = k.idx AND t.tool_call_index = k.tci",
467        );
468        for row in sqlx::query(&sql)
469            .bind(rids)
470            .bind(indices)
471            .bind(call_indices)
472            .fetch_all(&**pool)
473            .await?
474        {
475            let rid: String = row.try_get("response_id")?;
476            let idx: i64 = row.try_get("idx")?;
477            let tci: i64 = row.try_get("tci")?;
478            content.insert(
479                (*kind_table, rid, idx, Some(tci)),
480                Fetched::ToolCall {
481                    tool_call_id: row.try_get("tool_call_id")?,
482                    function_name: row.try_get("function_name")?,
483                    arguments: row.try_get("arguments")?,
484                },
485            );
486        }
487    }
488
489    for (kind_table, ids) in &queue_ids {
490        let Source::MessageQueue { table, kind } = source(*kind_table) else {
491            unreachable!("grouped as MessageQueue above");
492        };
493        let sql = format!(
494            "SELECT t.id, {} FROM {table} t WHERE t.id = ANY($1::bigint[])",
495            media_columns(kind),
496        );
497        for row in sqlx::query(&sql).bind(ids).fetch_all(&**pool).await? {
498            let id: i64 = row.try_get("id")?;
499            // Notification rows key by the content id in `row_index`;
500            // response_id is still part of the identity tuple.
501            for msg in msgs
502                .iter()
503                .filter(|m| m.table_kind == *kind_table && m.row_index == Some(id))
504            {
505                content.insert(
506                    (
507                        *kind_table,
508                        msg.response_id.clone().unwrap_or_default(),
509                        id,
510                        None,
511                    ),
512                    Fetched::Media(media_content(kind, &row)?),
513                );
514            }
515        }
516    }
517
518    if !error_ids.is_empty() {
519        for row in
520            sqlx::query("SELECT id, error FROM objectiveai.errors WHERE id = ANY($1::bigint[])")
521                .bind(&error_ids)
522                .fetch_all(&**pool)
523                .await?
524        {
525            let id: i64 = row.try_get("id")?;
526            let error: serde_json::Value = row.try_get("error")?;
527            errors_by_id.insert(id, error);
528        }
529    }
530
531    // Phase 3 — emit typed events in index order, blobs skipped,
532    // content inlined.
533    let mut out = Vec::with_capacity(msgs.len());
534    for msg in &msgs {
535        let delivered_at = unix_to_rfc3339(msg.timestamp_delivered);
536        let aih = msg.agent_instance_hierarchy.clone();
537
538        // `error` first — the one kind whose response_id may be NULL.
539        if msg.table_kind == MessageTable::Error {
540            let Some(error) = msg.row_index.and_then(|id| errors_by_id.get(&id)) else {
541                continue; // torn write
542            };
543            out.push(AgentInstanceEvent::Error {
544                agent_instance_hierarchy: aih,
545                response_id: msg.response_id.clone(),
546                error: error.clone(),
547                delivered_at,
548            });
549            continue;
550        }
551
552        // Every other kind requires its response_id.
553        let Some(response_id) = msg.response_id.clone() else {
554            continue;
555        };
556
557        match source(msg.table_kind) {
558            Source::Blob | Source::ErrorRow => continue,
559            Source::Vote => {
560                let Some(vote) = msg
561                    .vote
562                    .clone()
563                    .and_then(|v| serde_json::from_value(v).ok())
564                else {
565                    continue;
566                };
567                out.push(AgentInstanceEvent::VectorResponseVote {
568                    agent_instance_hierarchy: aih,
569                    response_id,
570                    vote,
571                });
572            }
573            _ => {
574                let row_index = msg.row_index.unwrap_or(0);
575                let key = (
576                    msg.table_kind,
577                    response_id.clone(),
578                    row_index,
579                    msg.row_sub_index,
580                );
581                let Some(fetched) = content.get(&key) else {
582                    // Torn write (metadata row without its content
583                    // row) — skip, exactly like a missing read_id.
584                    continue;
585                };
586                let Some(event) = build_event(
587                    msg,
588                    aih,
589                    response_id,
590                    row_index,
591                    delivered_at,
592                    fetched,
593                ) else {
594                    continue;
595                };
596                out.push(event);
597            }
598        }
599    }
600    Ok((out, next_after_id))
601}
602
603/// One metadata row + its fetched content → the typed event. `None`
604/// when a joined boundary field the event requires is missing (torn
605/// metadata — same skip policy as missing content).
606fn build_event(
607    msg: &MsgRow,
608    agent_instance_hierarchy: String,
609    response_id: String,
610    row_index: i64,
611    delivered_at: String,
612    fetched: &Fetched,
613) -> Option<AgentInstanceEvent> {
614    use MessageTable as T;
615    let row_sub_index = msg.row_sub_index;
616    Some(match msg.table_kind {
617        // ---- notifications ----
618        T::MessageQueueText
619        | T::MessageQueueImage
620        | T::MessageQueueAudio
621        | T::MessageQueueVideo
622        | T::MessageQueueFile => {
623            let Fetched::Media(content) = fetched else {
624                return None;
625            };
626            AgentInstanceEvent::ClientNotificationPart {
627                agent_instance_hierarchy,
628                response_id,
629                sender_agent_instance_hierarchy: msg
630                    .sender_agent_instance_hierarchy
631                    .clone()
632                    .unwrap_or_default(),
633                message_queue_id: msg.message_queue_id?,
634                queued_at: unix_to_rfc3339(msg.timestamp_queued.unwrap_or_default()),
635                key: msg.message_queue_key.clone(),
636                row_index,
637                part: ClientNotificationPart {
638                    delivered_at,
639                    content: content.clone(),
640                },
641            }
642        }
643
644        // ---- assistant (response side) ----
645        T::AssistantResponseRefusal => AgentInstanceEvent::AssistantResponsePart {
646            agent_instance_hierarchy,
647            response_id,
648            row_index,
649            row_sub_index,
650            part: AssistantResponsePart::Refusal {
651                delivered_at,
652                text: fetched_text(fetched)?,
653            },
654        },
655        T::AssistantResponseReasoning => AgentInstanceEvent::AssistantResponsePart {
656            agent_instance_hierarchy,
657            response_id,
658            row_index,
659            row_sub_index,
660            part: AssistantResponsePart::Reasoning {
661                delivered_at,
662                text: fetched_text(fetched)?,
663            },
664        },
665        T::AssistantResponseToolCalls => AgentInstanceEvent::AssistantResponsePart {
666            agent_instance_hierarchy,
667            response_id,
668            row_index,
669            row_sub_index,
670            part: tool_call_part(fetched, delivered_at, row_sub_index)?,
671        },
672        T::AssistantResponseContentText
673        | T::AssistantResponseContentImage
674        | T::AssistantResponseContentAudio
675        | T::AssistantResponseContentVideo
676        | T::AssistantResponseContentFile => {
677            let Fetched::Media(content) = fetched else {
678                return None;
679            };
680            AgentInstanceEvent::AssistantResponsePart {
681                agent_instance_hierarchy,
682                response_id,
683                row_index,
684                row_sub_index,
685                part: assistant_media(delivered_at, content.clone()),
686            }
687        }
688
689        // ---- assistant (request side) ----
690        T::RequestMessageAssistantRefusal => AgentInstanceEvent::RequestMessageAssistantPart {
691            agent_instance_hierarchy,
692            response_id,
693            row_index,
694            row_sub_index,
695            part: AssistantResponsePart::Refusal {
696                delivered_at,
697                text: fetched_text(fetched)?,
698            },
699        },
700        T::RequestMessageAssistantReasoning => AgentInstanceEvent::RequestMessageAssistantPart {
701            agent_instance_hierarchy,
702            response_id,
703            row_index,
704            row_sub_index,
705            part: AssistantResponsePart::Reasoning {
706                delivered_at,
707                text: fetched_text(fetched)?,
708            },
709        },
710        T::RequestMessageAssistantToolCalls => AgentInstanceEvent::RequestMessageAssistantPart {
711            agent_instance_hierarchy,
712            response_id,
713            row_index,
714            row_sub_index,
715            part: tool_call_part(fetched, delivered_at, row_sub_index)?,
716        },
717        T::RequestMessageAssistantContentText
718        | T::RequestMessageAssistantContentImage
719        | T::RequestMessageAssistantContentAudio
720        | T::RequestMessageAssistantContentVideo
721        | T::RequestMessageAssistantContentFile => {
722            let Fetched::Media(content) = fetched else {
723                return None;
724            };
725            AgentInstanceEvent::RequestMessageAssistantPart {
726                agent_instance_hierarchy,
727                response_id,
728                row_index,
729                row_sub_index,
730                part: assistant_media(delivered_at, content.clone()),
731            }
732        }
733
734        // ---- tool response (response side) ----
735        T::ToolResponseContentText
736        | T::ToolResponseContentImage
737        | T::ToolResponseContentAudio
738        | T::ToolResponseContentVideo
739        | T::ToolResponseContentFile => {
740            let Fetched::Media(content) = fetched else {
741                return None;
742            };
743            AgentInstanceEvent::ToolResponsePart {
744                agent_instance_hierarchy,
745                response_id,
746                tool_call_id: msg.tool_call_id.clone()?,
747                row_index,
748                row_sub_index,
749                part: ToolResponsePart {
750                    delivered_at,
751                    content: content.clone(),
752                },
753            }
754        }
755
756        // ---- tool response (request side) ----
757        T::RequestMessageToolContentText
758        | T::RequestMessageToolContentImage
759        | T::RequestMessageToolContentAudio
760        | T::RequestMessageToolContentVideo
761        | T::RequestMessageToolContentFile => {
762            let Fetched::Media(content) = fetched else {
763                return None;
764            };
765            AgentInstanceEvent::RequestMessageToolPart {
766                agent_instance_hierarchy,
767                response_id,
768                tool_call_id: msg.tool_call_id.clone()?,
769                row_index,
770                row_sub_index,
771                part: ToolResponsePart {
772                    delivered_at,
773                    content: content.clone(),
774                },
775            }
776        }
777
778        // ---- user ----
779        T::RequestMessageUserContentText
780        | T::RequestMessageUserContentImage
781        | T::RequestMessageUserContentAudio
782        | T::RequestMessageUserContentVideo
783        | T::RequestMessageUserContentFile => {
784            let Fetched::Media(content) = fetched else {
785                return None;
786            };
787            AgentInstanceEvent::RequestMessageUserPart {
788                agent_instance_hierarchy,
789                response_id,
790                row_index,
791                row_sub_index,
792                part: RequestMessageUserPart {
793                    delivered_at,
794                    content: content.clone(),
795                },
796            }
797        }
798
799        // ---- vector choices ----
800        T::RequestVectorChoiceContentText
801        | T::RequestVectorChoiceContentImage
802        | T::RequestVectorChoiceContentAudio
803        | T::RequestVectorChoiceContentVideo
804        | T::RequestVectorChoiceContentFile => {
805            let Fetched::Media(content) = fetched else {
806                return None;
807            };
808            AgentInstanceEvent::VectorRequestChoicePart {
809                agent_instance_hierarchy,
810                response_id,
811                key: msg.choice_key.clone()?,
812                choice_index: row_index,
813                part_index: row_sub_index?,
814                part: VectorRequestChoicePart {
815                    delivered_at,
816                    content: content.clone(),
817                },
818            }
819        }
820
821        // Handled before dispatch / never content-built.
822        T::AgentCompletionRequest
823        | T::VectorCompletionRequest
824        | T::FunctionExecutionRequest
825        | T::ResponseVectorVote
826        | T::Error => return None,
827    })
828}
829
830fn fetched_text(fetched: &Fetched) -> Option<String> {
831    match fetched {
832        Fetched::Text(text) => Some(text.clone()),
833        _ => None,
834    }
835}
836
837fn tool_call_part(
838    fetched: &Fetched,
839    delivered_at: String,
840    row_sub_index: Option<i64>,
841) -> Option<AssistantResponsePart> {
842    let Fetched::ToolCall {
843        tool_call_id,
844        function_name,
845        arguments,
846    } = fetched
847    else {
848        return None;
849    };
850    Some(AssistantResponsePart::ToolCall {
851        delivered_at,
852        function_name: function_name.clone(),
853        tool_call_id: tool_call_id.clone(),
854        tool_call_index: row_sub_index.unwrap_or(0),
855        arguments: arguments.clone(),
856    })
857}