Skip to main content

objectiveai_cli/db/logs/
rows.rs

1//! Free functions that walk a chunk and yield every [`WriterItem`] it
2//! implies — the streaming-content [`RowValue`]s plus, at each nested
3//! agent completion carrying usage, a [`WriterItem::Usage`] token-count
4//! snapshot. One entry point per top-level chunk type:
5//!
6//! - [`agent_completion_chunk_rows`]
7//! - [`vector_completion_chunk_rows`]
8//! - [`function_execution_chunk_rows`]
9//!
10//! The function-execution walker is recursive — it forwards into the
11//! vector walker (and back into itself for nested function tasks),
12//! which in turn forwards into the agent walker. No collection
13//! happens: each yielded item borrows from the input chunk and the
14//! writer drains the iterator one element at a time — a single
15//! traversal covers both rows and usage.
16//!
17//! Every yielded `RowValue` carries the enclosing agent-completion
18//! chunk's `response_id` AND `agent_instance_hierarchy`, so the
19//! writer can populate `objectiveai.messages` / `objectiveai.messages_queue`
20//! without a side-channel.
21
22use objectiveai_sdk::agent::completions::message::{
23    AssistantToolCall, Message, RichContent, RichContentPart,
24};
25use objectiveai_sdk::agent::completions::response::ToolResponse;
26use objectiveai_sdk::agent::completions::response::streaming::{
27    AgentCompletionChunk, AssistantResponseChunk, MessageChunk,
28};
29use objectiveai_sdk::functions::executions::response::streaming::{
30    FunctionExecutionChunk, TaskChunk, VectorCompletionTaskChunk,
31};
32use objectiveai_sdk::vector::completions::response::Vote;
33use objectiveai_sdk::vector::completions::response::streaming::VectorCompletionChunk;
34
35use super::row::{RowValue, RowsIter, WriterItem, WriterItems};
36
37/// Entry: walk an agent-completion chunk. Emits a [`WriterItem::Usage`]
38/// first when the chunk carries a non-`None` usage (its `total_tokens`
39/// snapshot), then every streaming-content row keyed by the chunk's own
40/// `id` and `agent_instance_hierarchy`.
41pub fn agent_completion_chunk_rows<'a>(
42    chunk: &'a AgentCompletionChunk,
43) -> WriterItems<'a> {
44    let response_id = chunk.id.as_str();
45    let agent_hierarchy = chunk.agent_instance_hierarchy.as_str();
46    let usage = chunk.usage.as_ref().map(move |u| WriterItem::Usage {
47        agent_instance_hierarchy: agent_hierarchy,
48        total_tokens: u.total_tokens,
49    });
50    // The completion's own in-band error, when set (lazy-set,
51    // cumulative — present on every subsequent chunk once set; the
52    // writer dedupes per (aih, response_id)).
53    let error = chunk.error.as_ref().map(move |error| WriterItem::Error {
54        agent_instance_hierarchy: agent_hierarchy,
55        response_id,
56        error,
57    });
58    let rows = chunk
59        .messages
60        .iter()
61        .flat_map(move |msg| message_chunk_rows(response_id, agent_hierarchy, msg))
62        .map(WriterItem::Row);
63    Box::new(usage.into_iter().chain(error).chain(rows))
64}
65
66/// Entry: walk every embedded per-agent completion in a vector chunk
67/// and forward to [`agent_completion_chunk_rows`] (usage included).
68pub fn vector_completion_chunk_rows<'a>(
69    chunk: &'a VectorCompletionChunk,
70) -> WriterItems<'a> {
71    Box::new(
72        chunk
73            .completions
74            .iter()
75            .flat_map(|c| agent_completion_chunk_rows(&c.inner)),
76    )
77}
78
79/// Entry: walk every task in a function chunk and forward to the
80/// matching tier walker. Reasoning summary's inner agent completion
81/// also flows through. Recursive: function tasks chain back into this
82/// function. Usage items interleave with rows via the leaf walker.
83pub fn function_execution_chunk_rows<'a>(
84    chunk: &'a FunctionExecutionChunk,
85) -> WriterItems<'a> {
86    let task_iter = chunk
87        .tasks
88        .iter()
89        .flat_map(|task| task_chunk_rows(task));
90    let reasoning_iter = chunk
91        .reasoning
92        .iter()
93        .flat_map(|r| agent_completion_chunk_rows(&r.inner));
94    Box::new(task_iter.chain(reasoning_iter))
95}
96
97/// One nested agent completion's liveness, borrowed from the folded
98/// cumulative chunk. `finished` = usage arrived (present only on a
99/// completion's final chunk); `errored` = the completion carries its
100/// own in-band error (already persisted via [`WriterItem::Error`]).
101/// The mid-stream failure sweep logs the stream error for every
102/// completion that is neither.
103pub struct CompletionStatus<'a> {
104    pub agent_instance_hierarchy: &'a str,
105    pub response_id: &'a str,
106    pub finished: bool,
107    pub errored: bool,
108}
109
110/// Boxed iterator of [`CompletionStatus`]es.
111pub type CompletionStatuses<'a> = Box<dyn Iterator<Item = CompletionStatus<'a>> + Send + 'a>;
112
113/// Status of the one completion an agent chunk IS.
114pub fn agent_completion_statuses<'a>(
115    chunk: &'a AgentCompletionChunk,
116) -> CompletionStatuses<'a> {
117    Box::new(std::iter::once(CompletionStatus {
118        agent_instance_hierarchy: chunk.agent_instance_hierarchy.as_str(),
119        response_id: chunk.id.as_str(),
120        finished: chunk.usage.is_some(),
121        errored: chunk.error.is_some(),
122    }))
123}
124
125/// Statuses of every embedded per-agent completion in a vector chunk.
126pub fn vector_completion_statuses<'a>(
127    chunk: &'a VectorCompletionChunk,
128) -> CompletionStatuses<'a> {
129    Box::new(
130        chunk
131            .completions
132            .iter()
133            .flat_map(|c| agent_completion_statuses(&c.inner)),
134    )
135}
136
137/// Statuses of every nested agent completion in a function chunk —
138/// recursive (function tasks chain back in), reasoning summaries
139/// included. Mirrors [`function_execution_chunk_rows`]'s traversal.
140pub fn function_execution_statuses<'a>(
141    chunk: &'a FunctionExecutionChunk,
142) -> CompletionStatuses<'a> {
143    let tasks = chunk.tasks.iter().flat_map(|task| match task {
144        TaskChunk::FunctionExecution(wrapper) => {
145            function_execution_statuses(&wrapper.inner)
146        }
147        TaskChunk::VectorCompletion(wrapper) => {
148            vector_completion_statuses(&wrapper.inner)
149        }
150    });
151    let reasoning = chunk
152        .reasoning
153        .iter()
154        .flat_map(|r| agent_completion_statuses(&r.inner));
155    Box::new(tasks.chain(reasoning))
156}
157
158// ---- internal helpers -------------------------------------------------
159
160fn task_chunk_rows<'a>(task: &'a TaskChunk) -> WriterItems<'a> {
161    match task {
162        TaskChunk::FunctionExecution(wrapper) => {
163            function_execution_chunk_rows(&wrapper.inner)
164        }
165        TaskChunk::VectorCompletion(wrapper) => vector_task_rows(wrapper),
166    }
167}
168
169/// A function-execution vector-completion task: for each per-agent
170/// completion, emit — in order — the task's request messages, the
171/// choices (with this agent's inline voting key), the agent's own
172/// response rows, then this agent's vote (closer). Every emitted row
173/// uses the per-agent AGENT-COMPLETION `response_id`
174/// (`c.inner.id`) — never the vector/task id — so `read all` groups
175/// them under the agent completion.
176fn vector_task_rows<'a>(
177    wrapper: &'a VectorCompletionTaskChunk,
178) -> WriterItems<'a> {
179    let vc = &wrapper.inner;
180    let request_messages = wrapper.request_messages.as_deref();
181    let request_choices = wrapper.request_choices.as_deref();
182    Box::new(vc.completions.iter().flat_map(move |c| {
183        let agent = &c.inner;
184        let response_id = agent.id.as_str();
185        let aih = agent.agent_instance_hierarchy.as_str();
186
187        let req_msgs: RowsIter<'a> = match request_messages {
188            Some(msgs) => request_message_rows(response_id, aih, msgs),
189            None => Box::new(std::iter::empty()),
190        };
191        let choices: RowsIter<'a> =
192            match (request_choices, c.request_choice_keys.as_deref()) {
193                (Some(chs), Some(keys)) => {
194                    vector_choice_rows(response_id, aih, chs, keys)
195                }
196                _ => Box::new(std::iter::empty()),
197            };
198        let vote = vote_row(response_id, aih, &vc.votes, c.index)
199            .map(WriterItem::Row);
200
201        Box::new(
202            req_msgs
203                .map(WriterItem::Row)
204                .chain(choices.map(WriterItem::Row))
205                .chain(agent_completion_chunk_rows(agent))
206                .chain(vote),
207        ) as WriterItems<'a>
208    }))
209}
210
211/// This agent's vote for the task, matched by `completion_index` —
212/// which equals the vector wrapper's `index`. This is an exact match
213/// even when swarm `count > 1` gives several completions the same
214/// `agent_id`.
215fn vote_row<'a>(
216    response_id: &'a str,
217    agent_instance_hierarchy: &'a str,
218    votes: &'a [Vote],
219    completion_index: u64,
220) -> Option<RowValue<'a>> {
221    votes
222        .iter()
223        .find(|v| v.completion_index == Some(completion_index))
224        .map(|v| RowValue::ResponseVectorVote {
225            response_id,
226            agent_instance_hierarchy,
227            vote: v.vote.as_slice(),
228        })
229}
230
231/// One choice per entry in `choices`, keyed by choice index: the head
232/// row (this agent's `keys[i]`) then the choice's content parts.
233fn vector_choice_rows<'a>(
234    response_id: &'a str,
235    agent_instance_hierarchy: &'a str,
236    choices: &'a [RichContent],
237    keys: &'a [String],
238) -> RowsIter<'a> {
239    Box::new(choices.iter().enumerate().flat_map(move |(i, content)| {
240        let choice_index = i as u64;
241        let key = keys.get(i).map(|k| k.as_str()).unwrap_or("");
242        let head = std::iter::once(RowValue::RequestVectorChoice {
243            response_id,
244            agent_instance_hierarchy,
245            choice_index,
246            key,
247        });
248        Box::new(head.chain(request_content_rows(
249            RequestTarget::VectorChoice,
250            response_id,
251            agent_instance_hierarchy,
252            choice_index,
253            content,
254        ))) as RowsIter<'a>
255    }))
256}
257
258/// Walk a request/task `Vec<Message>` into request_message rows,
259/// paralleling the response walk but into the `request_message_*`
260/// tables. `index` is the message's position in the array. Shared by
261/// the function-tier task walk (from `task.request_messages`) and the
262/// agent-tier writer (from the request body).
263pub(super) fn request_message_rows<'a>(
264    response_id: &'a str,
265    aih: &'a str,
266    messages: &'a [Message],
267) -> RowsIter<'a> {
268    Box::new(messages.iter().enumerate().flat_map(move |(i, msg)| {
269        let index = i as u64;
270        let out: RowsIter<'a> = match msg {
271            Message::User(u) => {
272                request_content_rows(RequestTarget::User, response_id, aih, index, &u.content)
273            }
274            Message::Assistant(a) => {
275                // reasoning → tool_calls → content → refusal
276                let reasoning = a.reasoning.iter().map(move |t| {
277                    RowValue::RequestMessageAssistantReasoning {
278                        response_id,
279                        agent_instance_hierarchy: aih,
280                        index,
281                        text: t.as_str(),
282                    }
283                });
284                let tool_calls =
285                    a.tool_calls.iter().flatten().enumerate().map(move |(tci, tc)| {
286                        let AssistantToolCall::Function { id, function } = tc;
287                        RowValue::RequestMessageAssistantToolCalls {
288                            response_id,
289                            agent_instance_hierarchy: aih,
290                            index,
291                            tool_call_index: tci as u64,
292                            tool_call_id: id.as_str(),
293                            function_name: function.name.as_str(),
294                            arguments: function.arguments.as_str(),
295                        }
296                    });
297                let content = a.content.iter().flat_map(move |c| {
298                    request_content_rows(RequestTarget::Assistant, response_id, aih, index, c)
299                });
300                let refusal = a.refusal.iter().map(move |t| {
301                    RowValue::RequestMessageAssistantRefusal {
302                        response_id,
303                        agent_instance_hierarchy: aih,
304                        index,
305                        text: t.as_str(),
306                    }
307                });
308                Box::new(reasoning.chain(tool_calls).chain(content).chain(refusal))
309            }
310            Message::Tool(t) => {
311                let head = std::iter::once(RowValue::RequestMessageTool {
312                    response_id,
313                    agent_instance_hierarchy: aih,
314                    index,
315                    tool_call_id: t.tool_call_id.as_str(),
316                });
317                Box::new(head.chain(request_content_rows(
318                    RequestTarget::Tool,
319                    response_id,
320                    aih,
321                    index,
322                    &t.content,
323                )))
324            }
325        };
326        out
327    }))
328}
329
330/// Which request content table group a content part targets.
331#[derive(Debug, Clone, Copy)]
332enum RequestTarget {
333    User,
334    Assistant,
335    Tool,
336    VectorChoice,
337}
338
339fn request_content_rows<'a>(
340    target: RequestTarget,
341    response_id: &'a str,
342    aih: &'a str,
343    index: u64,
344    content: &'a RichContent,
345) -> RowsIter<'a> {
346    match content {
347        RichContent::Text(text) => Box::new(std::iter::once(request_content_text(
348            target, response_id, aih, index, 0, text.as_str(),
349        ))),
350        RichContent::Parts(parts) => {
351            Box::new(parts.iter().enumerate().map(move |(pi, part)| {
352                request_content_part(target, response_id, aih, index, pi as u64, part)
353            }))
354        }
355    }
356}
357
358fn request_content_text<'a>(
359    target: RequestTarget,
360    response_id: &'a str,
361    agent_instance_hierarchy: &'a str,
362    index: u64,
363    part_index: u64,
364    text: &'a str,
365) -> RowValue<'a> {
366    match target {
367        RequestTarget::User => RowValue::RequestMessageUserContentText { response_id, agent_instance_hierarchy, index, part_index, text },
368        RequestTarget::Assistant => RowValue::RequestMessageAssistantContentText { response_id, agent_instance_hierarchy, index, part_index, text },
369        RequestTarget::Tool => RowValue::RequestMessageToolContentText { response_id, agent_instance_hierarchy, index, part_index, text },
370        RequestTarget::VectorChoice => RowValue::RequestVectorChoiceContentText { response_id, agent_instance_hierarchy, choice_index: index, part_index, text },
371    }
372}
373
374fn request_content_part<'a>(
375    target: RequestTarget,
376    response_id: &'a str,
377    agent_instance_hierarchy: &'a str,
378    index: u64,
379    part_index: u64,
380    part: &'a RichContentPart,
381) -> RowValue<'a> {
382    match part {
383        RichContentPart::Text { text } => {
384            request_content_text(target, response_id, agent_instance_hierarchy, index, part_index, text.as_str())
385        }
386        RichContentPart::ImageUrl { image_url } => match target {
387            RequestTarget::User => RowValue::RequestMessageUserContentImage { response_id, agent_instance_hierarchy, index, part_index, image_url },
388            RequestTarget::Assistant => RowValue::RequestMessageAssistantContentImage { response_id, agent_instance_hierarchy, index, part_index, image_url },
389            RequestTarget::Tool => RowValue::RequestMessageToolContentImage { response_id, agent_instance_hierarchy, index, part_index, image_url },
390            RequestTarget::VectorChoice => RowValue::RequestVectorChoiceContentImage { response_id, agent_instance_hierarchy, choice_index: index, part_index, image_url },
391        },
392        RichContentPart::InputAudio { input_audio } => match target {
393            RequestTarget::User => RowValue::RequestMessageUserContentAudio { response_id, agent_instance_hierarchy, index, part_index, input_audio },
394            RequestTarget::Assistant => RowValue::RequestMessageAssistantContentAudio { response_id, agent_instance_hierarchy, index, part_index, input_audio },
395            RequestTarget::Tool => RowValue::RequestMessageToolContentAudio { response_id, agent_instance_hierarchy, index, part_index, input_audio },
396            RequestTarget::VectorChoice => RowValue::RequestVectorChoiceContentAudio { response_id, agent_instance_hierarchy, choice_index: index, part_index, input_audio },
397        },
398        RichContentPart::InputVideo { video_url } | RichContentPart::VideoUrl { video_url } => match target {
399            RequestTarget::User => RowValue::RequestMessageUserContentVideo { response_id, agent_instance_hierarchy, index, part_index, video_url },
400            RequestTarget::Assistant => RowValue::RequestMessageAssistantContentVideo { response_id, agent_instance_hierarchy, index, part_index, video_url },
401            RequestTarget::Tool => RowValue::RequestMessageToolContentVideo { response_id, agent_instance_hierarchy, index, part_index, video_url },
402            RequestTarget::VectorChoice => RowValue::RequestVectorChoiceContentVideo { response_id, agent_instance_hierarchy, choice_index: index, part_index, video_url },
403        },
404        RichContentPart::File { file } => match target {
405            RequestTarget::User => RowValue::RequestMessageUserContentFile { response_id, agent_instance_hierarchy, index, part_index, file },
406            RequestTarget::Assistant => RowValue::RequestMessageAssistantContentFile { response_id, agent_instance_hierarchy, index, part_index, file },
407            RequestTarget::Tool => RowValue::RequestMessageToolContentFile { response_id, agent_instance_hierarchy, index, part_index, file },
408            RequestTarget::VectorChoice => RowValue::RequestVectorChoiceContentFile { response_id, agent_instance_hierarchy, choice_index: index, part_index, file },
409        },
410    }
411}
412
413fn message_chunk_rows<'a>(
414    response_id: &'a str,
415    agent_instance_hierarchy: &'a str,
416    msg: &'a MessageChunk,
417) -> RowsIter<'a> {
418    match msg {
419        MessageChunk::Assistant(a) => {
420            assistant_response_chunk_rows(response_id, agent_instance_hierarchy, a)
421        }
422        MessageChunk::Tool(t) => {
423            tool_response_rows(response_id, agent_instance_hierarchy, t)
424        }
425    }
426}
427
428fn assistant_response_chunk_rows<'a>(
429    response_id: &'a str,
430    agent_instance_hierarchy: &'a str,
431    chunk: &'a AssistantResponseChunk,
432) -> RowsIter<'a> {
433    let index = chunk.index;
434
435    // Prepend: `MessageQueueContent` rows for every consumed
436    // `message_queue_contents.id` the API stamped. Yielded ahead
437    // of the message body so `objectiveai.messages` chronicles
438    // consumption before the body the agent produced from it.
439    let message_queue_iter = chunk
440        .request_message_ids
441        .as_deref()
442        .unwrap_or(&[])
443        .iter()
444        .copied()
445        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
446            response_id,
447            agent_instance_hierarchy,
448            message_queue_content_id,
449        });
450
451    // Emission order: reasoning → tool_calls → content → refusal.
452    // Refusal goes last on purpose — when a model refuses it's
453    // typically the terminal signal of the turn, so readers see all
454    // the actual work (reasoning / tool calls / content parts) before
455    // the refusal stamp.
456    let reasoning_iter = chunk.reasoning.iter().map(move |text| {
457        RowValue::AssistantResponseReasoning {
458            response_id,
459            agent_instance_hierarchy,
460            index,
461            text: text.as_str(),
462        }
463    });
464    let tool_calls_iter = chunk
465        .tool_calls
466        .iter()
467        .flatten()
468        .filter_map(move |tc| {
469            let id = tc.id.as_deref()?;
470            let name = tc.function.as_ref().and_then(|f| f.name.as_deref())?;
471            let args = tc.function.as_ref().and_then(|f| f.arguments.as_deref())?;
472            Some(RowValue::AssistantResponseToolCalls {
473                response_id,
474                agent_instance_hierarchy,
475                index,
476                // The tool call's own wire `index`, NOT its position in
477                // the merged Vec. `push` correlates streamed tool-call
478                // deltas by `index`, so a call whose wire index is e.g.
479                // 2 can sit at Vec position 0 (or move as earlier-index
480                // deltas arrive). The row PK is
481                // (response_id, index, tool_call_index) — keying it on
482                // Vec position would mislabel calls and let the shadow
483                // key drift across chunks; the wire index is stable.
484                tool_call_index: tc.index,
485                tool_call_id: id,
486                function_name: name,
487                arguments: args,
488            })
489        });
490    let content_iter = chunk
491        .content
492        .iter()
493        .flat_map(move |c| assistant_content_rows(response_id, agent_instance_hierarchy, index, c));
494    let refusal_iter = chunk.refusal.iter().map(move |text| {
495        RowValue::AssistantResponseRefusal {
496            response_id,
497            agent_instance_hierarchy,
498            index,
499            text: text.as_str(),
500        }
501    });
502
503    Box::new(
504        message_queue_iter
505            .chain(reasoning_iter)
506            .chain(tool_calls_iter)
507            .chain(content_iter)
508            .chain(refusal_iter),
509    )
510}
511
512fn tool_response_rows<'a>(
513    response_id: &'a str,
514    agent_instance_hierarchy: &'a str,
515    response: &'a ToolResponse,
516) -> RowsIter<'a> {
517    let index = response.index;
518    // Same MessageQueueContent prepend as the assistant path —
519    // surfaced for wire-shape symmetry. Currently the API never
520    // populates `ToolResponse.request_message_ids`, so this iter
521    // is empty in practice.
522    let message_queue_iter = response
523        .request_message_ids
524        .as_deref()
525        .unwrap_or(&[])
526        .iter()
527        .copied()
528        .map(move |message_queue_content_id| RowValue::MessageQueueContent {
529            response_id,
530            agent_instance_hierarchy,
531            message_queue_content_id,
532        });
533    let head = std::iter::once(RowValue::ToolResponse {
534        response_id,
535        agent_instance_hierarchy,
536        index,
537        tool_call_id: response.inner.tool_call_id.as_str(),
538    });
539    Box::new(
540        message_queue_iter.chain(head).chain(tool_content_rows(
541            response_id,
542            agent_instance_hierarchy,
543            index,
544            &response.inner.content,
545        )),
546    )
547}
548
549fn assistant_content_rows<'a>(
550    response_id: &'a str,
551    agent_instance_hierarchy: &'a str,
552    index: u64,
553    content: &'a RichContent,
554) -> RowsIter<'a> {
555    match content {
556        RichContent::Text(text) => Box::new(std::iter::once(RowValue::AssistantResponseContentText {
557            response_id,
558            agent_instance_hierarchy,
559            index,
560            part_index: 0,
561            text: text.as_str(),
562        })),
563        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
564            assistant_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
565        })),
566    }
567}
568
569fn assistant_content_part<'a>(
570    response_id: &'a str,
571    agent_instance_hierarchy: &'a str,
572    index: u64,
573    part_index: u64,
574    part: &'a RichContentPart,
575) -> RowValue<'a> {
576    match part {
577        RichContentPart::Text { text } => RowValue::AssistantResponseContentText {
578            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
579        },
580        RichContentPart::ImageUrl { image_url } => RowValue::AssistantResponseContentImage {
581            response_id, agent_instance_hierarchy, index, part_index, image_url,
582        },
583        RichContentPart::InputAudio { input_audio } => RowValue::AssistantResponseContentAudio {
584            response_id, agent_instance_hierarchy, index, part_index, input_audio,
585        },
586        RichContentPart::InputVideo { video_url }
587        | RichContentPart::VideoUrl { video_url } => RowValue::AssistantResponseContentVideo {
588            response_id, agent_instance_hierarchy, index, part_index, video_url,
589        },
590        RichContentPart::File { file } => RowValue::AssistantResponseContentFile {
591            response_id, agent_instance_hierarchy, index, part_index, file,
592        },
593    }
594}
595
596fn tool_content_rows<'a>(
597    response_id: &'a str,
598    agent_instance_hierarchy: &'a str,
599    index: u64,
600    content: &'a RichContent,
601) -> RowsIter<'a> {
602    match content {
603        RichContent::Text(text) => Box::new(std::iter::once(RowValue::ToolResponseContentText {
604            response_id,
605            agent_instance_hierarchy,
606            index,
607            part_index: 0,
608            text: text.as_str(),
609        })),
610        RichContent::Parts(parts) => Box::new(parts.iter().enumerate().map(move |(part_index, part)| {
611            tool_content_part(response_id, agent_instance_hierarchy, index, part_index as u64, part)
612        })),
613    }
614}
615
616fn tool_content_part<'a>(
617    response_id: &'a str,
618    agent_instance_hierarchy: &'a str,
619    index: u64,
620    part_index: u64,
621    part: &'a RichContentPart,
622) -> RowValue<'a> {
623    match part {
624        RichContentPart::Text { text } => RowValue::ToolResponseContentText {
625            response_id, agent_instance_hierarchy, index, part_index, text: text.as_str(),
626        },
627        RichContentPart::ImageUrl { image_url } => RowValue::ToolResponseContentImage {
628            response_id, agent_instance_hierarchy, index, part_index, image_url,
629        },
630        RichContentPart::InputAudio { input_audio } => RowValue::ToolResponseContentAudio {
631            response_id, agent_instance_hierarchy, index, part_index, input_audio,
632        },
633        RichContentPart::InputVideo { video_url }
634        | RichContentPart::VideoUrl { video_url } => RowValue::ToolResponseContentVideo {
635            response_id, agent_instance_hierarchy, index, part_index, video_url,
636        },
637        RichContentPart::File { file } => RowValue::ToolResponseContentFile {
638            response_id, agent_instance_hierarchy, index, part_index, file,
639        },
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use objectiveai_sdk::agent::completions::response::Usage;
647
648    fn agent_chunk(aih: &str, total: Option<u64>) -> AgentCompletionChunk {
649        AgentCompletionChunk {
650            agent_instance_hierarchy: aih.to_string(),
651            usage: total.map(|t| Usage { total_tokens: t, ..Default::default() }),
652            ..Default::default()
653        }
654    }
655
656    /// Collect just the `Usage` items from a walk.
657    fn usages<'a>(items: WriterItems<'a>) -> Vec<(&'a str, u64)> {
658        items
659            .filter_map(|item| match item {
660                WriterItem::Usage { agent_instance_hierarchy, total_tokens } => {
661                    Some((agent_instance_hierarchy, total_tokens))
662                }
663                WriterItem::Row(_) | WriterItem::Error { .. } => None,
664            })
665            .collect()
666    }
667
668    #[test]
669    fn agent_walk_emits_usage_only_when_present() {
670        assert_eq!(
671            usages(agent_completion_chunk_rows(&agent_chunk("a/b", Some(42)))),
672            vec![("a/b", 42)]
673        );
674        assert!(usages(agent_completion_chunk_rows(&agent_chunk("a/b", None))).is_empty());
675    }
676
677    #[test]
678    fn vector_walk_surfaces_nested_usage_and_skips_none() {
679        use objectiveai_sdk::vector::completions::response::streaming::AgentCompletionChunk as VecAgent;
680        let chunk = VectorCompletionChunk {
681            completions: vec![
682                VecAgent { index: 0, inner: agent_chunk("a/x", Some(10)), ..Default::default() },
683                VecAgent { index: 1, inner: agent_chunk("a/y", None), ..Default::default() },
684                VecAgent { index: 2, inner: agent_chunk("a/z", Some(7)), ..Default::default() },
685            ],
686            ..Default::default()
687        };
688        assert_eq!(
689            usages(vector_completion_chunk_rows(&chunk)),
690            vec![("a/x", 10), ("a/z", 7)]
691        );
692    }
693}