Skip to main content

objectiveai_cli/db/logs/
row.rs

1//! Row-shape types: [`RowTable`] / [`MessageTable`] / [`RowValue`] /
2//! [`RowKey`] / [`OwnedRowKey`] / [`RowBody`].
3//!
4//! - [`RowValue<'a>`] — what the chunk-row iterators yield. Borrowed
5//!   sum type, one variant per streaming-content table. Every variant
6//!   carries `response_id` (the enclosing agent_completion_chunk's id)
7//!   AND `agent_instance_hierarchy` (the enclosing chunk's spawned
8//!   agent id) so the writer can address `objectiveai.messages` /
9//!   `objectiveai.messages_queue` without going back to the chunk.
10//! - [`RowKey<'a>`] — what [`RowValue::key`] returns. Borrowed key
11//!   used for shadow-map lookups; no allocation.
12//! - [`OwnedRowKey`] — same variants as `RowKey`, but with `String`
13//!   instead of `&str`. Only built on insert (cold path).
14//! - [`RowBody`] — owned body sum, one variant per table. Used by the
15//!   shadow to store the last-written body so the next tick can
16//!   [`RowValue::body_eq`] against it without re-hashing or
17//!   re-serializing.
18//!
19//! Hash invariant: `RowKey<'a>` and `OwnedRowKey` MUST produce the
20//! same `u64` hash for the same logical key. Since `&str` and `String`
21//! hash identically and the variant tag is determined by source
22//! position (matching variants are at matching positions in the two
23//! enums), `derive(Hash)` is sufficient — no manual impl needed.
24
25use objectiveai_sdk::agent::completions::message::{File, ImageUrl, InputAudio, VideoUrl};
26
27/// Every table in the `logs.*` schema, plus the synthetic
28/// `MessageQueueContent` variant for queue-consumption rows.
29/// The latter writes to `objectiveai.messages` with a `"table"` value
30/// chosen at write time via SQL CASE (one of `message_queue_text`,
31/// `_image`, `_audio`, `_video`, `_file`) — the Rust-side variant
32/// is kind-less because the dispatch happens in SQL.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum RowTable {
35    AgentCompletionRequests,
36    AgentCompletionResponses,
37    VectorCompletionRequests,
38    VectorCompletionResponses,
39    FunctionExecutionRequests,
40    FunctionExecutionResponses,
41
42    /// Synthetic — kind-less at the Rust level. The writer's
43    /// helper picks the per-kind `objectiveai.message_table` enum value
44    /// via SQL CASE against `message_queue_contents.kind` and
45    /// flips the parent `message_queue.active = FALSE` in the
46    /// same statement.
47    MessageQueueContent,
48
49    ToolResponse,
50
51    AssistantResponseRefusal,
52    AssistantResponseReasoning,
53    AssistantResponseToolCalls,
54
55    AssistantResponseContentText,
56    AssistantResponseContentImage,
57    AssistantResponseContentAudio,
58    AssistantResponseContentVideo,
59    AssistantResponseContentFile,
60
61    ToolResponseContentText,
62    ToolResponseContentImage,
63    ToolResponseContentAudio,
64    ToolResponseContentVideo,
65    ToolResponseContentFile,
66}
67
68/// The subset of [`RowTable`] that produces a `objectiveai.messages` event
69/// row when written. Maps 1:1 to the postgres `objectiveai.message_table`
70/// ENUM in `schema.sql` — same names, same order. The three
71/// response-blob tables are intentionally absent; they're not events,
72/// just the latest snapshot. `tool_response` is also absent: its head
73/// row is written purely as the `tool_call_id` lookup for tool-response
74/// content rows (JOINed at read time) and emits no event of its own.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, sqlx::Type)]
76#[sqlx(type_name = "objectiveai.message_table", rename_all = "snake_case")]
77pub enum MessageTable {
78    AgentCompletionRequest,
79    VectorCompletionRequest,
80    FunctionExecutionRequest,
81    MessageQueueText,
82    MessageQueueImage,
83    MessageQueueAudio,
84    MessageQueueVideo,
85    MessageQueueFile,
86    AssistantResponseRefusal,
87    AssistantResponseReasoning,
88    AssistantResponseToolCalls,
89    AssistantResponseContentText,
90    AssistantResponseContentImage,
91    AssistantResponseContentAudio,
92    AssistantResponseContentVideo,
93    AssistantResponseContentFile,
94    ToolResponseContentText,
95    ToolResponseContentImage,
96    ToolResponseContentAudio,
97    ToolResponseContentVideo,
98    ToolResponseContentFile,
99}
100
101impl MessageTable {
102    /// snake_case schema name — matches the postgres enum value
103    /// declared in `schema.sql`. Used to build `IN (...)` filter
104    /// clauses inline since sqlx doesn't auto-derive
105    /// `PgHasArrayType` for custom enum types.
106    pub fn schema_name(self) -> &'static str {
107        match self {
108            MessageTable::AgentCompletionRequest => "agent_completion_request",
109            MessageTable::VectorCompletionRequest => "vector_completion_request",
110            MessageTable::FunctionExecutionRequest => "function_execution_request",
111            MessageTable::MessageQueueText => "message_queue_text",
112            MessageTable::MessageQueueImage => "message_queue_image",
113            MessageTable::MessageQueueAudio => "message_queue_audio",
114            MessageTable::MessageQueueVideo => "message_queue_video",
115            MessageTable::MessageQueueFile => "message_queue_file",
116            MessageTable::AssistantResponseRefusal => "assistant_response_refusal",
117            MessageTable::AssistantResponseReasoning => "assistant_response_reasoning",
118            MessageTable::AssistantResponseToolCalls => "assistant_response_tool_calls",
119            MessageTable::AssistantResponseContentText => "assistant_response_content_text",
120            MessageTable::AssistantResponseContentImage => "assistant_response_content_image",
121            MessageTable::AssistantResponseContentAudio => "assistant_response_content_audio",
122            MessageTable::AssistantResponseContentVideo => "assistant_response_content_video",
123            MessageTable::AssistantResponseContentFile => "assistant_response_content_file",
124            MessageTable::ToolResponseContentText => "tool_response_content_text",
125            MessageTable::ToolResponseContentImage => "tool_response_content_image",
126            MessageTable::ToolResponseContentAudio => "tool_response_content_audio",
127            MessageTable::ToolResponseContentVideo => "tool_response_content_video",
128            MessageTable::ToolResponseContentFile => "tool_response_content_file",
129        }
130    }
131}
132
133impl RowTable {
134    /// The [`MessageTable`] for this table's events. Returns `None` for
135    /// the three response-blob tables (which don't emit messages).
136    pub fn message_table(self) -> Option<MessageTable> {
137        Some(match self {
138            RowTable::AgentCompletionRequests => MessageTable::AgentCompletionRequest,
139            RowTable::VectorCompletionRequests => MessageTable::VectorCompletionRequest,
140            RowTable::FunctionExecutionRequests => MessageTable::FunctionExecutionRequest,
141            // MessageQueueContent's table value is resolved at
142            // write time via SQL CASE — the standard
143            // `message_table()` path can't pick from the 5
144            // per-kind variants without the kind. Callers writing
145            // these rows skip this helper.
146            RowTable::MessageQueueContent => return None,
147            // The tool-response head row is written to
148            // `objectiveai.tool_response` purely as the `tool_call_id`
149            // lookup for its content rows (JOINed at read time). It
150            // emits no `messages` event, so it's never addressable by
151            // `agents logs read id` and never appears as its own part
152            // in `read all`. write.rs MUST early-branch this variant
153            // before calling `RowValue::message_table()` (which
154            // `.expect()`s a Some) — see `insert_value`/`update_value`.
155            RowTable::ToolResponse => return None,
156            RowTable::AssistantResponseRefusal => MessageTable::AssistantResponseRefusal,
157            RowTable::AssistantResponseReasoning => MessageTable::AssistantResponseReasoning,
158            RowTable::AssistantResponseToolCalls => MessageTable::AssistantResponseToolCalls,
159            RowTable::AssistantResponseContentText => MessageTable::AssistantResponseContentText,
160            RowTable::AssistantResponseContentImage => MessageTable::AssistantResponseContentImage,
161            RowTable::AssistantResponseContentAudio => MessageTable::AssistantResponseContentAudio,
162            RowTable::AssistantResponseContentVideo => MessageTable::AssistantResponseContentVideo,
163            RowTable::AssistantResponseContentFile => MessageTable::AssistantResponseContentFile,
164            RowTable::ToolResponseContentText => MessageTable::ToolResponseContentText,
165            RowTable::ToolResponseContentImage => MessageTable::ToolResponseContentImage,
166            RowTable::ToolResponseContentAudio => MessageTable::ToolResponseContentAudio,
167            RowTable::ToolResponseContentVideo => MessageTable::ToolResponseContentVideo,
168            RowTable::ToolResponseContentFile => MessageTable::ToolResponseContentFile,
169            RowTable::AgentCompletionResponses
170            | RowTable::VectorCompletionResponses
171            | RowTable::FunctionExecutionResponses => return None,
172        })
173    }
174
175    pub fn fq_name(self) -> &'static str {
176        match self {
177            RowTable::AgentCompletionRequests => "objectiveai.agent_completion_requests",
178            RowTable::AgentCompletionResponses => "objectiveai.agent_completion_responses",
179            RowTable::VectorCompletionRequests => "objectiveai.vector_completion_requests",
180            RowTable::VectorCompletionResponses => "objectiveai.vector_completion_responses",
181            RowTable::FunctionExecutionRequests => "objectiveai.function_execution_requests",
182            RowTable::FunctionExecutionResponses => "objectiveai.function_execution_responses",
183            // Synthetic — kind chosen at write time via SQL CASE.
184            // No per-kind table name surfaces through fq_name();
185            // the writer's helper builds its SQL inline.
186            RowTable::MessageQueueContent => "message_queue_contents",
187            RowTable::ToolResponse => "objectiveai.tool_response",
188            RowTable::AssistantResponseRefusal => "objectiveai.assistant_response_refusal",
189            RowTable::AssistantResponseReasoning => "objectiveai.assistant_response_reasoning",
190            RowTable::AssistantResponseToolCalls => "objectiveai.assistant_response_tool_calls",
191            RowTable::AssistantResponseContentText => "objectiveai.assistant_response_content_text",
192            RowTable::AssistantResponseContentImage => "objectiveai.assistant_response_content_image",
193            RowTable::AssistantResponseContentAudio => "objectiveai.assistant_response_content_audio",
194            RowTable::AssistantResponseContentVideo => "objectiveai.assistant_response_content_video",
195            RowTable::AssistantResponseContentFile => "objectiveai.assistant_response_content_file",
196            RowTable::ToolResponseContentText => "objectiveai.tool_response_content_text",
197            RowTable::ToolResponseContentImage => "objectiveai.tool_response_content_image",
198            RowTable::ToolResponseContentAudio => "objectiveai.tool_response_content_audio",
199            RowTable::ToolResponseContentVideo => "objectiveai.tool_response_content_video",
200            RowTable::ToolResponseContentFile => "objectiveai.tool_response_content_file",
201        }
202    }
203}
204
205/// One streaming-content row to INSERT or UPDATE. Borrowed: every
206/// variant lifts string / media payloads from the owning chunk by
207/// reference. Every variant also carries the enclosing chunk's
208/// `agent_instance_hierarchy` so the writer can populate
209/// `objectiveai.messages.agent_instance_hierarchy` and key the
210/// `objectiveai.messages_queue` downgrade against the right spawned agent.
211#[derive(Debug, Clone)]
212pub enum RowValue<'a> {
213    /// Consumption signal: the API stamped this
214    /// `message_queue_contents.id` onto the chunk's
215    /// `request_message_ids` field. The writer's helper:
216    /// 1) resolves the row's kind via SQL CASE against
217    ///    `message_queue_contents.kind`,
218    /// 2) `UPDATE objectiveai.message_queue SET active=FALSE WHERE id =
219    ///    (SELECT message_queue_id FROM objectiveai.message_queue_contents
220    ///     WHERE id = $content_id) AND active=TRUE`, and
221    /// 3) `INSERT objectiveai.messages` with `"table"` picked from the
222    ///    five `message_queue_*` enum values matching the kind
223    ///    and `row_index = content_id`, so the read path
224    ///    dispatches directly to the right per-kind table.
225    ///
226    /// Multiple content_ids sharing one parent fire the flip
227    /// once; the rest are no-ops via the `AND active=TRUE` guard.
228    ///
229    /// Iterators yield these AHEAD of the per-message content
230    /// rows so the log chronicles consumption before the body
231    /// the agent produced.
232    MessageQueueContent {
233        response_id: &'a str,
234        agent_instance_hierarchy: &'a str,
235        message_queue_content_id: i64,
236    },
237    ToolResponse {
238        response_id: &'a str,
239        agent_instance_hierarchy: &'a str,
240        index: u64,
241        tool_call_id: &'a str,
242    },
243    AssistantResponseRefusal {
244        response_id: &'a str,
245        agent_instance_hierarchy: &'a str,
246        index: u64,
247        text: &'a str,
248    },
249    AssistantResponseReasoning {
250        response_id: &'a str,
251        agent_instance_hierarchy: &'a str,
252        index: u64,
253        text: &'a str,
254    },
255    AssistantResponseToolCalls {
256        response_id: &'a str,
257        agent_instance_hierarchy: &'a str,
258        index: u64,
259        tool_call_index: u64,
260        tool_call_id: &'a str,
261        function_name: &'a str,
262        arguments: &'a str,
263    },
264
265    AssistantResponseContentText {
266        response_id: &'a str,
267        agent_instance_hierarchy: &'a str,
268        index: u64,
269        part_index: u64,
270        text: &'a str,
271    },
272    AssistantResponseContentImage {
273        response_id: &'a str,
274        agent_instance_hierarchy: &'a str,
275        index: u64,
276        part_index: u64,
277        image_url: &'a ImageUrl,
278    },
279    AssistantResponseContentAudio {
280        response_id: &'a str,
281        agent_instance_hierarchy: &'a str,
282        index: u64,
283        part_index: u64,
284        input_audio: &'a InputAudio,
285    },
286    AssistantResponseContentVideo {
287        response_id: &'a str,
288        agent_instance_hierarchy: &'a str,
289        index: u64,
290        part_index: u64,
291        video_url: &'a VideoUrl,
292    },
293    AssistantResponseContentFile {
294        response_id: &'a str,
295        agent_instance_hierarchy: &'a str,
296        index: u64,
297        part_index: u64,
298        file: &'a File,
299    },
300
301    ToolResponseContentText {
302        response_id: &'a str,
303        agent_instance_hierarchy: &'a str,
304        index: u64,
305        part_index: u64,
306        text: &'a str,
307    },
308    ToolResponseContentImage {
309        response_id: &'a str,
310        agent_instance_hierarchy: &'a str,
311        index: u64,
312        part_index: u64,
313        image_url: &'a ImageUrl,
314    },
315    ToolResponseContentAudio {
316        response_id: &'a str,
317        agent_instance_hierarchy: &'a str,
318        index: u64,
319        part_index: u64,
320        input_audio: &'a InputAudio,
321    },
322    ToolResponseContentVideo {
323        response_id: &'a str,
324        agent_instance_hierarchy: &'a str,
325        index: u64,
326        part_index: u64,
327        video_url: &'a VideoUrl,
328    },
329    ToolResponseContentFile {
330        response_id: &'a str,
331        agent_instance_hierarchy: &'a str,
332        index: u64,
333        part_index: u64,
334        file: &'a File,
335    },
336}
337
338impl<'a> RowValue<'a> {
339    pub fn table(&self) -> RowTable {
340        match self {
341            RowValue::MessageQueueContent { .. } => RowTable::MessageQueueContent,
342            RowValue::ToolResponse { .. } => RowTable::ToolResponse,
343            RowValue::AssistantResponseRefusal { .. } => RowTable::AssistantResponseRefusal,
344            RowValue::AssistantResponseReasoning { .. } => RowTable::AssistantResponseReasoning,
345            RowValue::AssistantResponseToolCalls { .. } => RowTable::AssistantResponseToolCalls,
346            RowValue::AssistantResponseContentText { .. } => RowTable::AssistantResponseContentText,
347            RowValue::AssistantResponseContentImage { .. } => RowTable::AssistantResponseContentImage,
348            RowValue::AssistantResponseContentAudio { .. } => RowTable::AssistantResponseContentAudio,
349            RowValue::AssistantResponseContentVideo { .. } => RowTable::AssistantResponseContentVideo,
350            RowValue::AssistantResponseContentFile { .. } => RowTable::AssistantResponseContentFile,
351            RowValue::ToolResponseContentText { .. } => RowTable::ToolResponseContentText,
352            RowValue::ToolResponseContentImage { .. } => RowTable::ToolResponseContentImage,
353            RowValue::ToolResponseContentAudio { .. } => RowTable::ToolResponseContentAudio,
354            RowValue::ToolResponseContentVideo { .. } => RowTable::ToolResponseContentVideo,
355            RowValue::ToolResponseContentFile { .. } => RowTable::ToolResponseContentFile,
356        }
357    }
358
359    /// [`MessageTable`] for this row's table. Streaming-content rows
360    /// always have one — EXCEPT `RowValue::ToolResponse`, whose head
361    /// row emits no `messages` event (it's a `tool_call_id` lookup
362    /// only). Callers MUST early-branch `ToolResponse` before calling
363    /// this (`write.rs::insert_value`/`update_value` do), or the
364    /// `.expect()` below panics.
365    pub fn message_table(&self) -> MessageTable {
366        self.table()
367            .message_table()
368            .expect("RowValue variants (except ToolResponse) cover messages-emitting tables")
369    }
370
371    /// `response_id` borrowed from the immediately-enclosing
372    /// agent-completion chunk's `id`. Even for rows emitted under
373    /// `vector_completion_chunk_rows` /
374    /// `function_execution_chunk_rows`, the recursion bottoms out
375    /// at an agent-completion chunk and uses that chunk's id —
376    /// never the outer vector/function wrapper's id. This is what
377    /// `agents logs read all` / `read pending` use as the
378    /// per-block `response_id` boundary key.
379    pub fn response_id(&self) -> &'a str {
380        match self {
381            RowValue::MessageQueueContent { response_id, .. }
382            | RowValue::ToolResponse { response_id, .. }
383            | RowValue::AssistantResponseRefusal { response_id, .. }
384            | RowValue::AssistantResponseReasoning { response_id, .. }
385            | RowValue::AssistantResponseToolCalls { response_id, .. }
386            | RowValue::AssistantResponseContentText { response_id, .. }
387            | RowValue::AssistantResponseContentImage { response_id, .. }
388            | RowValue::AssistantResponseContentAudio { response_id, .. }
389            | RowValue::AssistantResponseContentVideo { response_id, .. }
390            | RowValue::AssistantResponseContentFile { response_id, .. }
391            | RowValue::ToolResponseContentText { response_id, .. }
392            | RowValue::ToolResponseContentImage { response_id, .. }
393            | RowValue::ToolResponseContentAudio { response_id, .. }
394            | RowValue::ToolResponseContentVideo { response_id, .. }
395            | RowValue::ToolResponseContentFile { response_id, .. } => response_id,
396        }
397    }
398
399    /// `agent_instance_hierarchy` borrowed from the enclosing
400    /// agent-completion chunk. Every streaming-content row lives
401    /// inside an agent completion, so this is always non-NULL.
402    pub fn agent_instance_hierarchy(&self) -> &'a str {
403        match self {
404            RowValue::MessageQueueContent { agent_instance_hierarchy, .. }
405            | RowValue::ToolResponse { agent_instance_hierarchy, .. }
406            | RowValue::AssistantResponseRefusal { agent_instance_hierarchy, .. }
407            | RowValue::AssistantResponseReasoning { agent_instance_hierarchy, .. }
408            | RowValue::AssistantResponseToolCalls { agent_instance_hierarchy, .. }
409            | RowValue::AssistantResponseContentText { agent_instance_hierarchy, .. }
410            | RowValue::AssistantResponseContentImage { agent_instance_hierarchy, .. }
411            | RowValue::AssistantResponseContentAudio { agent_instance_hierarchy, .. }
412            | RowValue::AssistantResponseContentVideo { agent_instance_hierarchy, .. }
413            | RowValue::AssistantResponseContentFile { agent_instance_hierarchy, .. }
414            | RowValue::ToolResponseContentText { agent_instance_hierarchy, .. }
415            | RowValue::ToolResponseContentImage { agent_instance_hierarchy, .. }
416            | RowValue::ToolResponseContentAudio { agent_instance_hierarchy, .. }
417            | RowValue::ToolResponseContentVideo { agent_instance_hierarchy, .. }
418            | RowValue::ToolResponseContentFile { agent_instance_hierarchy, .. } => {
419                agent_instance_hierarchy
420            }
421        }
422    }
423
424    /// `row_index` column value for the postgres `messages` /
425    /// `messages_queue` entry. Always populated for streaming-content
426    /// rows.
427    pub fn row_index(&self) -> i64 {
428        match self {
429            RowValue::MessageQueueContent { message_queue_content_id, .. } => {
430                *message_queue_content_id
431            }
432            RowValue::ToolResponse { index, .. }
433            | RowValue::AssistantResponseRefusal { index, .. }
434            | RowValue::AssistantResponseReasoning { index, .. }
435            | RowValue::AssistantResponseToolCalls { index, .. }
436            | RowValue::AssistantResponseContentText { index, .. }
437            | RowValue::AssistantResponseContentImage { index, .. }
438            | RowValue::AssistantResponseContentAudio { index, .. }
439            | RowValue::AssistantResponseContentVideo { index, .. }
440            | RowValue::AssistantResponseContentFile { index, .. }
441            | RowValue::ToolResponseContentText { index, .. }
442            | RowValue::ToolResponseContentImage { index, .. }
443            | RowValue::ToolResponseContentAudio { index, .. }
444            | RowValue::ToolResponseContentVideo { index, .. }
445            | RowValue::ToolResponseContentFile { index, .. } => *index as i64,
446        }
447    }
448
449    /// `row_sub_index` column value: `tool_call_index` for tool-call
450    /// rows, `part_index` for content-part rows, `None` for
451    /// tool_response / assistant refusal / assistant reasoning (whose
452    /// shape has no sub-index). The matching SQL column is nullable.
453    pub fn row_sub_index(&self) -> Option<i64> {
454        match self {
455            RowValue::MessageQueueContent { .. }
456            | RowValue::ToolResponse { .. }
457            | RowValue::AssistantResponseRefusal { .. }
458            | RowValue::AssistantResponseReasoning { .. } => None,
459            RowValue::AssistantResponseToolCalls { tool_call_index, .. } => {
460                Some(*tool_call_index as i64)
461            }
462            RowValue::AssistantResponseContentText { part_index, .. }
463            | RowValue::AssistantResponseContentImage { part_index, .. }
464            | RowValue::AssistantResponseContentAudio { part_index, .. }
465            | RowValue::AssistantResponseContentVideo { part_index, .. }
466            | RowValue::AssistantResponseContentFile { part_index, .. }
467            | RowValue::ToolResponseContentText { part_index, .. }
468            | RowValue::ToolResponseContentImage { part_index, .. }
469            | RowValue::ToolResponseContentAudio { part_index, .. }
470            | RowValue::ToolResponseContentVideo { part_index, .. }
471            | RowValue::ToolResponseContentFile { part_index, .. } => Some(*part_index as i64),
472        }
473    }
474
475    /// The borrowed key that identifies this row's slot in postgres.
476    /// Hashable + Eq — used by the shadow map to find the previously
477    /// stored body without any allocation. Two `RowValue`s targeting
478    /// the same row produce equal `RowKey`s.
479    pub fn key(&self) -> RowKey<'a> {
480        match self {
481            RowValue::MessageQueueContent {
482                response_id,
483                message_queue_content_id,
484                ..
485            } => RowKey::MessageQueueContent {
486                response_id,
487                message_queue_content_id: *message_queue_content_id,
488            },
489            RowValue::ToolResponse { response_id, index, .. } => {
490                RowKey::ToolResponse { response_id, index: *index }
491            }
492            RowValue::AssistantResponseRefusal { response_id, index, .. } => {
493                RowKey::AssistantRefusal { response_id, index: *index }
494            }
495            RowValue::AssistantResponseReasoning { response_id, index, .. } => {
496                RowKey::AssistantReasoning { response_id, index: *index }
497            }
498            RowValue::AssistantResponseToolCalls {
499                response_id, index, tool_call_index, ..
500            } => RowKey::AssistantToolCall {
501                response_id,
502                index: *index,
503                tool_call_index: *tool_call_index,
504            },
505            RowValue::AssistantResponseContentText { response_id, index, part_index, .. } => {
506                RowKey::AssistantContentText { response_id, index: *index, part_index: *part_index }
507            }
508            RowValue::AssistantResponseContentImage { response_id, index, part_index, .. } => {
509                RowKey::AssistantContentImage { response_id, index: *index, part_index: *part_index }
510            }
511            RowValue::AssistantResponseContentAudio { response_id, index, part_index, .. } => {
512                RowKey::AssistantContentAudio { response_id, index: *index, part_index: *part_index }
513            }
514            RowValue::AssistantResponseContentVideo { response_id, index, part_index, .. } => {
515                RowKey::AssistantContentVideo { response_id, index: *index, part_index: *part_index }
516            }
517            RowValue::AssistantResponseContentFile { response_id, index, part_index, .. } => {
518                RowKey::AssistantContentFile { response_id, index: *index, part_index: *part_index }
519            }
520            RowValue::ToolResponseContentText { response_id, index, part_index, .. } => {
521                RowKey::ToolContentText { response_id, index: *index, part_index: *part_index }
522            }
523            RowValue::ToolResponseContentImage { response_id, index, part_index, .. } => {
524                RowKey::ToolContentImage { response_id, index: *index, part_index: *part_index }
525            }
526            RowValue::ToolResponseContentAudio { response_id, index, part_index, .. } => {
527                RowKey::ToolContentAudio { response_id, index: *index, part_index: *part_index }
528            }
529            RowValue::ToolResponseContentVideo { response_id, index, part_index, .. } => {
530                RowKey::ToolContentVideo { response_id, index: *index, part_index: *part_index }
531            }
532            RowValue::ToolResponseContentFile { response_id, index, part_index, .. } => {
533                RowKey::ToolContentFile { response_id, index: *index, part_index: *part_index }
534            }
535        }
536    }
537
538    /// Field-by-field equality against a stored body. Returns true
539    /// when this row would write a byte-identical body — the writer
540    /// uses that signal to short-circuit the SQL.
541    pub fn body_eq(&self, stored: &RowBody) -> bool {
542        match (self, stored) {
543            // MessageQueueContent has no body — the row's identity is
544            // entirely in the (response_id, content_id) key; shadow
545            // skip-dedup makes the second write a no-op via this true.
546            (
547                RowValue::MessageQueueContent { .. },
548                RowBody::MessageQueueContent {},
549            ) => true,
550            (
551                RowValue::ToolResponse { tool_call_id: a, .. },
552                RowBody::ToolResponse { tool_call_id: b },
553            ) => *a == b.as_str(),
554            (
555                RowValue::AssistantResponseRefusal { text: a, .. },
556                RowBody::AssistantRefusal { text: b },
557            ) => *a == b.as_str(),
558            (
559                RowValue::AssistantResponseReasoning { text: a, .. },
560                RowBody::AssistantReasoning { text: b },
561            ) => *a == b.as_str(),
562            (
563                RowValue::AssistantResponseToolCalls { tool_call_id: a, arguments: aa, .. },
564                RowBody::AssistantToolCall { tool_call_id: b, arguments: bb },
565            ) => *a == b.as_str() && *aa == bb.as_str(),
566            (
567                RowValue::AssistantResponseContentText { text: a, .. },
568                RowBody::AssistantContentText { text: b },
569            ) => *a == b.as_str(),
570            (
571                RowValue::AssistantResponseContentImage { image_url: a, .. },
572                RowBody::AssistantContentImage { image_url: b },
573            ) => *a == b,
574            (
575                RowValue::AssistantResponseContentAudio { input_audio: a, .. },
576                RowBody::AssistantContentAudio { input_audio: b },
577            ) => *a == b,
578            (
579                RowValue::AssistantResponseContentVideo { video_url: a, .. },
580                RowBody::AssistantContentVideo { video_url: b },
581            ) => *a == b,
582            (
583                RowValue::AssistantResponseContentFile { file: a, .. },
584                RowBody::AssistantContentFile { file: b },
585            ) => *a == b,
586            (
587                RowValue::ToolResponseContentText { text: a, .. },
588                RowBody::ToolContentText { text: b },
589            ) => *a == b.as_str(),
590            (
591                RowValue::ToolResponseContentImage { image_url: a, .. },
592                RowBody::ToolContentImage { image_url: b },
593            ) => *a == b,
594            (
595                RowValue::ToolResponseContentAudio { input_audio: a, .. },
596                RowBody::ToolContentAudio { input_audio: b },
597            ) => *a == b,
598            (
599                RowValue::ToolResponseContentVideo { video_url: a, .. },
600                RowBody::ToolContentVideo { video_url: b },
601            ) => *a == b,
602            (
603                RowValue::ToolResponseContentFile { file: a, .. },
604                RowBody::ToolContentFile { file: b },
605            ) => *a == b,
606            _ => false,
607        }
608    }
609
610    /// Build an owned [`RowBody`] for storing on Insert / Update. Only
611    /// called on the cold path (when the shadow needs to remember a
612    /// new value); the Skip path never allocates here.
613    pub fn to_body(&self) -> RowBody {
614        match self {
615            RowValue::MessageQueueContent { .. } => RowBody::MessageQueueContent {},
616            RowValue::ToolResponse { tool_call_id, .. } => RowBody::ToolResponse {
617                tool_call_id: (*tool_call_id).to_owned(),
618            },
619            RowValue::AssistantResponseRefusal { text, .. } => RowBody::AssistantRefusal {
620                text: (*text).to_owned(),
621            },
622            RowValue::AssistantResponseReasoning { text, .. } => RowBody::AssistantReasoning {
623                text: (*text).to_owned(),
624            },
625            RowValue::AssistantResponseToolCalls { tool_call_id, arguments, .. } => {
626                RowBody::AssistantToolCall {
627                    tool_call_id: (*tool_call_id).to_owned(),
628                    arguments: (*arguments).to_owned(),
629                }
630            }
631            RowValue::AssistantResponseContentText { text, .. } => RowBody::AssistantContentText {
632                text: (*text).to_owned(),
633            },
634            RowValue::AssistantResponseContentImage { image_url, .. } => {
635                RowBody::AssistantContentImage { image_url: (*image_url).clone() }
636            }
637            RowValue::AssistantResponseContentAudio { input_audio, .. } => {
638                RowBody::AssistantContentAudio { input_audio: (*input_audio).clone() }
639            }
640            RowValue::AssistantResponseContentVideo { video_url, .. } => {
641                RowBody::AssistantContentVideo {
642                    video_url: (*video_url).clone(),
643                }
644            }
645            RowValue::AssistantResponseContentFile { file, .. } => {
646                RowBody::AssistantContentFile { file: (*file).clone() }
647            }
648            RowValue::ToolResponseContentText { text, .. } => RowBody::ToolContentText {
649                text: (*text).to_owned(),
650            },
651            RowValue::ToolResponseContentImage { image_url, .. } => {
652                RowBody::ToolContentImage { image_url: (*image_url).clone() }
653            }
654            RowValue::ToolResponseContentAudio { input_audio, .. } => {
655                RowBody::ToolContentAudio { input_audio: (*input_audio).clone() }
656            }
657            RowValue::ToolResponseContentVideo { video_url, .. } => RowBody::ToolContentVideo {
658                video_url: (*video_url).clone(),
659            },
660            RowValue::ToolResponseContentFile { file, .. } => RowBody::ToolContentFile {
661                file: (*file).clone(),
662            },
663        }
664    }
665}
666
667/// Boxed-iterator alias used at the recursive boundaries
668/// (function execution → vector completion → agent completion).
669/// One Box per recursive descent, never per leaf row.
670pub type RowsIter<'a> = Box<dyn Iterator<Item = RowValue<'a>> + Send + 'a>;
671
672/// One item from a top-level chunk walk: either a streaming-content
673/// [`RowValue`] or a per-AIH agent-completion token-usage snapshot.
674/// Usage rides the SAME single traversal as the content rows — the
675/// walk already descends into every nested agent completion, so the
676/// writer never re-walks the chunk tree for usage.
677#[derive(Debug)]
678pub enum WriterItem<'a> {
679    Row(RowValue<'a>),
680    Usage {
681        agent_instance_hierarchy: &'a str,
682        total_tokens: u64,
683    },
684}
685
686/// Boxed iterator of [`WriterItem`]s — the return type of the three
687/// entry-point chunk walkers. Internal per-message helpers still yield
688/// bare [`RowValue`] via [`RowsIter`]; only the entry points interleave
689/// the usage item.
690pub type WriterItems<'a> = Box<dyn Iterator<Item = WriterItem<'a>> + Send + 'a>;
691
692// ---------------------------------------------------------------------
693// Shadow keys (borrowed + owned)
694// ---------------------------------------------------------------------
695
696/// Borrowed key that identifies one row's slot in postgres. Two
697/// `RowKey`s with equal variant + fields hash identically to an
698/// [`OwnedRowKey`] with the matching shape — that invariant lets the
699/// shadow's hashbrown `raw_entry_mut` look up by borrowed key without
700/// converting to owned first.
701#[derive(Debug, Clone, Hash, PartialEq, Eq)]
702pub enum RowKey<'a> {
703    /// `message_queue_contents.id` is globally unique across
704    /// kinds, so the key needs no kind discriminator — only the
705    /// id (the kind is recovered from `RowValue` for write
706    /// dispatch).
707    MessageQueueContent { response_id: &'a str, message_queue_content_id: i64 },
708    ToolResponse { response_id: &'a str, index: u64 },
709    AssistantRefusal { response_id: &'a str, index: u64 },
710    AssistantReasoning { response_id: &'a str, index: u64 },
711    AssistantToolCall { response_id: &'a str, index: u64, tool_call_index: u64 },
712    AssistantContentText { response_id: &'a str, index: u64, part_index: u64 },
713    AssistantContentImage { response_id: &'a str, index: u64, part_index: u64 },
714    AssistantContentAudio { response_id: &'a str, index: u64, part_index: u64 },
715    AssistantContentVideo { response_id: &'a str, index: u64, part_index: u64 },
716    AssistantContentFile { response_id: &'a str, index: u64, part_index: u64 },
717    ToolContentText { response_id: &'a str, index: u64, part_index: u64 },
718    ToolContentImage { response_id: &'a str, index: u64, part_index: u64 },
719    ToolContentAudio { response_id: &'a str, index: u64, part_index: u64 },
720    ToolContentVideo { response_id: &'a str, index: u64, part_index: u64 },
721    ToolContentFile { response_id: &'a str, index: u64, part_index: u64 },
722}
723
724/// Owned counterpart to [`RowKey`]. Stored in the shadow map. Built
725/// only on Insert.
726#[derive(Debug, Clone, Hash, PartialEq, Eq)]
727pub enum OwnedRowKey {
728    MessageQueueContent { response_id: String, message_queue_content_id: i64 },
729    ToolResponse { response_id: String, index: u64 },
730    AssistantRefusal { response_id: String, index: u64 },
731    AssistantReasoning { response_id: String, index: u64 },
732    AssistantToolCall { response_id: String, index: u64, tool_call_index: u64 },
733    AssistantContentText { response_id: String, index: u64, part_index: u64 },
734    AssistantContentImage { response_id: String, index: u64, part_index: u64 },
735    AssistantContentAudio { response_id: String, index: u64, part_index: u64 },
736    AssistantContentVideo { response_id: String, index: u64, part_index: u64 },
737    AssistantContentFile { response_id: String, index: u64, part_index: u64 },
738    ToolContentText { response_id: String, index: u64, part_index: u64 },
739    ToolContentImage { response_id: String, index: u64, part_index: u64 },
740    ToolContentAudio { response_id: String, index: u64, part_index: u64 },
741    ToolContentVideo { response_id: String, index: u64, part_index: u64 },
742    ToolContentFile { response_id: String, index: u64, part_index: u64 },
743}
744
745impl<'a> RowKey<'a> {
746    pub fn matches_owned(&self, owned: &OwnedRowKey) -> bool {
747        match (self, owned) {
748            (
749                RowKey::MessageQueueContent { response_id: a, message_queue_content_id: ai },
750                OwnedRowKey::MessageQueueContent { response_id: b, message_queue_content_id: bi },
751            ) => *a == b.as_str() && ai == bi,
752            (
753                RowKey::ToolResponse { response_id: a, index: ai },
754                OwnedRowKey::ToolResponse { response_id: b, index: bi },
755            ) => *a == b.as_str() && ai == bi,
756            (
757                RowKey::AssistantRefusal { response_id: a, index: ai },
758                OwnedRowKey::AssistantRefusal { response_id: b, index: bi },
759            ) => *a == b.as_str() && ai == bi,
760            (
761                RowKey::AssistantReasoning { response_id: a, index: ai },
762                OwnedRowKey::AssistantReasoning { response_id: b, index: bi },
763            ) => *a == b.as_str() && ai == bi,
764            (
765                RowKey::AssistantToolCall { response_id: a, index: ai, tool_call_index: at },
766                OwnedRowKey::AssistantToolCall { response_id: b, index: bi, tool_call_index: bt },
767            ) => *a == b.as_str() && ai == bi && at == bt,
768            (
769                RowKey::AssistantContentText { response_id: a, index: ai, part_index: ap },
770                OwnedRowKey::AssistantContentText { response_id: b, index: bi, part_index: bp },
771            ) => *a == b.as_str() && ai == bi && ap == bp,
772            (
773                RowKey::AssistantContentImage { response_id: a, index: ai, part_index: ap },
774                OwnedRowKey::AssistantContentImage { response_id: b, index: bi, part_index: bp },
775            ) => *a == b.as_str() && ai == bi && ap == bp,
776            (
777                RowKey::AssistantContentAudio { response_id: a, index: ai, part_index: ap },
778                OwnedRowKey::AssistantContentAudio { response_id: b, index: bi, part_index: bp },
779            ) => *a == b.as_str() && ai == bi && ap == bp,
780            (
781                RowKey::AssistantContentVideo { response_id: a, index: ai, part_index: ap },
782                OwnedRowKey::AssistantContentVideo { response_id: b, index: bi, part_index: bp },
783            ) => *a == b.as_str() && ai == bi && ap == bp,
784            (
785                RowKey::AssistantContentFile { response_id: a, index: ai, part_index: ap },
786                OwnedRowKey::AssistantContentFile { response_id: b, index: bi, part_index: bp },
787            ) => *a == b.as_str() && ai == bi && ap == bp,
788            (
789                RowKey::ToolContentText { response_id: a, index: ai, part_index: ap },
790                OwnedRowKey::ToolContentText { response_id: b, index: bi, part_index: bp },
791            ) => *a == b.as_str() && ai == bi && ap == bp,
792            (
793                RowKey::ToolContentImage { response_id: a, index: ai, part_index: ap },
794                OwnedRowKey::ToolContentImage { response_id: b, index: bi, part_index: bp },
795            ) => *a == b.as_str() && ai == bi && ap == bp,
796            (
797                RowKey::ToolContentAudio { response_id: a, index: ai, part_index: ap },
798                OwnedRowKey::ToolContentAudio { response_id: b, index: bi, part_index: bp },
799            ) => *a == b.as_str() && ai == bi && ap == bp,
800            (
801                RowKey::ToolContentVideo { response_id: a, index: ai, part_index: ap },
802                OwnedRowKey::ToolContentVideo { response_id: b, index: bi, part_index: bp },
803            ) => *a == b.as_str() && ai == bi && ap == bp,
804            (
805                RowKey::ToolContentFile { response_id: a, index: ai, part_index: ap },
806                OwnedRowKey::ToolContentFile { response_id: b, index: bi, part_index: bp },
807            ) => *a == b.as_str() && ai == bi && ap == bp,
808            _ => false,
809        }
810    }
811
812    pub fn to_owned_key(&self) -> OwnedRowKey {
813        match self {
814            RowKey::MessageQueueContent { response_id, message_queue_content_id } => {
815                OwnedRowKey::MessageQueueContent {
816                    response_id: (*response_id).to_owned(),
817                    message_queue_content_id: *message_queue_content_id,
818                }
819            }
820            RowKey::ToolResponse { response_id, index } => OwnedRowKey::ToolResponse {
821                response_id: (*response_id).to_owned(),
822                index: *index,
823            },
824            RowKey::AssistantRefusal { response_id, index } => OwnedRowKey::AssistantRefusal {
825                response_id: (*response_id).to_owned(),
826                index: *index,
827            },
828            RowKey::AssistantReasoning { response_id, index } => OwnedRowKey::AssistantReasoning {
829                response_id: (*response_id).to_owned(),
830                index: *index,
831            },
832            RowKey::AssistantToolCall { response_id, index, tool_call_index } => {
833                OwnedRowKey::AssistantToolCall {
834                    response_id: (*response_id).to_owned(),
835                    index: *index,
836                    tool_call_index: *tool_call_index,
837                }
838            }
839            RowKey::AssistantContentText { response_id, index, part_index } => {
840                OwnedRowKey::AssistantContentText {
841                    response_id: (*response_id).to_owned(),
842                    index: *index,
843                    part_index: *part_index,
844                }
845            }
846            RowKey::AssistantContentImage { response_id, index, part_index } => {
847                OwnedRowKey::AssistantContentImage {
848                    response_id: (*response_id).to_owned(),
849                    index: *index,
850                    part_index: *part_index,
851                }
852            }
853            RowKey::AssistantContentAudio { response_id, index, part_index } => {
854                OwnedRowKey::AssistantContentAudio {
855                    response_id: (*response_id).to_owned(),
856                    index: *index,
857                    part_index: *part_index,
858                }
859            }
860            RowKey::AssistantContentVideo { response_id, index, part_index } => {
861                OwnedRowKey::AssistantContentVideo {
862                    response_id: (*response_id).to_owned(),
863                    index: *index,
864                    part_index: *part_index,
865                }
866            }
867            RowKey::AssistantContentFile { response_id, index, part_index } => {
868                OwnedRowKey::AssistantContentFile {
869                    response_id: (*response_id).to_owned(),
870                    index: *index,
871                    part_index: *part_index,
872                }
873            }
874            RowKey::ToolContentText { response_id, index, part_index } => {
875                OwnedRowKey::ToolContentText {
876                    response_id: (*response_id).to_owned(),
877                    index: *index,
878                    part_index: *part_index,
879                }
880            }
881            RowKey::ToolContentImage { response_id, index, part_index } => {
882                OwnedRowKey::ToolContentImage {
883                    response_id: (*response_id).to_owned(),
884                    index: *index,
885                    part_index: *part_index,
886                }
887            }
888            RowKey::ToolContentAudio { response_id, index, part_index } => {
889                OwnedRowKey::ToolContentAudio {
890                    response_id: (*response_id).to_owned(),
891                    index: *index,
892                    part_index: *part_index,
893                }
894            }
895            RowKey::ToolContentVideo { response_id, index, part_index } => {
896                OwnedRowKey::ToolContentVideo {
897                    response_id: (*response_id).to_owned(),
898                    index: *index,
899                    part_index: *part_index,
900                }
901            }
902            RowKey::ToolContentFile { response_id, index, part_index } => {
903                OwnedRowKey::ToolContentFile {
904                    response_id: (*response_id).to_owned(),
905                    index: *index,
906                    part_index: *part_index,
907                }
908            }
909        }
910    }
911}
912
913// ---------------------------------------------------------------------
914// Shadow body
915// ---------------------------------------------------------------------
916
917/// Owned body stored in the shadow map. Compared by `PartialEq`
918/// against an incoming [`RowValue`] via [`RowValue::body_eq`].
919#[derive(Debug, Clone, PartialEq)]
920pub enum RowBody {
921    /// Empty marker — `MessageQueueContent` rows have no body; the
922    /// shadow uses presence-only for skip detection (body_eq
923    /// returns true for any matching key, so the second sight of
924    /// the same content_id is treated as Skip).
925    MessageQueueContent {},
926    ToolResponse { tool_call_id: String },
927    AssistantRefusal { text: String },
928    AssistantReasoning { text: String },
929    AssistantToolCall { tool_call_id: String, arguments: String },
930    AssistantContentText { text: String },
931    AssistantContentImage { image_url: ImageUrl },
932    AssistantContentAudio { input_audio: InputAudio },
933    AssistantContentVideo { video_url: VideoUrl },
934    AssistantContentFile { file: File },
935    ToolContentText { text: String },
936    ToolContentImage { image_url: ImageUrl },
937    ToolContentAudio { input_audio: InputAudio },
938    ToolContentVideo { video_url: VideoUrl },
939    ToolContentFile { file: File },
940}