1use std::collections::HashMap;
5use std::path::PathBuf;
6
7use serde_json::{Map, Value};
8use toolpath_convo::{
9 ConversationProjector, ConversationView, ConvoError, Result, Role, ToolInvocation, Turn,
10};
11
12use crate::types::{
13 AssistantMessage, Message, MessageData, MessagePath, MessageTime, ModelRef, Part, PartData,
14 ReasoningPart, Session, StepFinishPart, StepStartPart, TextPart, TimeRange, Tokens, ToolPart,
15 ToolRunTime, ToolState, ToolStateCompleted, ToolStateError, UserMessage,
16};
17
18const DEFAULT_AGENT: &str = "build";
19const DEFAULT_MODEL_PROVIDER: &str = "anthropic";
20const DEFAULT_MODEL_ID: &str = "unknown";
21const DEFAULT_VERSION: &str = "0.0.0-projected";
22
23#[derive(Debug, Clone, Default)]
24pub struct OpencodeProjector {
25 pub project_id: Option<String>,
26 pub directory: Option<PathBuf>,
27 pub workspace_id: Option<String>,
28 pub agent: Option<String>,
29 pub default_model_provider: Option<String>,
30 pub default_model_id: Option<String>,
31 pub version: Option<String>,
32 pub slug: Option<String>,
33 pub title: Option<String>,
34}
35
36impl OpencodeProjector {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn with_project_id(mut self, id: impl Into<String>) -> Self {
42 self.project_id = Some(id.into());
43 self
44 }
45
46 pub fn with_directory(mut self, dir: impl Into<PathBuf>) -> Self {
47 self.directory = Some(dir.into());
48 self
49 }
50
51 pub fn with_workspace_id(mut self, id: impl Into<String>) -> Self {
52 self.workspace_id = Some(id.into());
53 self
54 }
55
56 pub fn with_agent(mut self, agent: impl Into<String>) -> Self {
57 self.agent = Some(agent.into());
58 self
59 }
60
61 pub fn with_version(mut self, v: impl Into<String>) -> Self {
62 self.version = Some(v.into());
63 self
64 }
65
66 pub fn with_title(mut self, title: impl Into<String>) -> Self {
67 self.title = Some(title.into());
68 self
69 }
70}
71
72impl ConversationProjector for OpencodeProjector {
73 type Output = Session;
74
75 fn project(&self, view: &ConversationView) -> Result<Session> {
76 project_view(self, view).map_err(ConvoError::Provider)
77 }
78}
79
80fn project_view(
81 cfg: &OpencodeProjector,
82 view: &ConversationView,
83) -> std::result::Result<Session, String> {
84 let directory = cfg
85 .directory
86 .clone()
87 .or_else(|| {
88 view.turns
89 .iter()
90 .find_map(|t| t.environment.as_ref()?.working_dir.clone())
91 .map(PathBuf::from)
92 })
93 .unwrap_or_else(|| PathBuf::from("/"));
94
95 let project_id = cfg
96 .project_id
97 .clone()
98 .unwrap_or_else(|| derive_project_id(&directory));
99
100 let agent = cfg
101 .agent
102 .clone()
103 .unwrap_or_else(|| DEFAULT_AGENT.to_string());
104 let version = cfg
105 .version
106 .clone()
107 .unwrap_or_else(|| DEFAULT_VERSION.to_string());
108
109 let session_id = if view.id.starts_with("ses_") {
110 view.id.clone()
111 } else {
112 mint_session_id(&view.id)
113 };
114
115 let time_created = view
116 .started_at
117 .map(|t| t.timestamp_millis())
118 .or_else(|| {
119 view.turns
120 .first()
121 .and_then(|t| parse_timestamp_ms(&t.timestamp))
122 })
123 .unwrap_or(0);
124 let time_updated = view
125 .last_activity
126 .map(|t| t.timestamp_millis())
127 .or_else(|| {
128 view.turns
129 .last()
130 .and_then(|t| parse_timestamp_ms(&t.timestamp))
131 })
132 .unwrap_or(time_created);
133
134 let title = cfg
135 .title
136 .clone()
137 .or_else(|| {
138 view.turns
139 .iter()
140 .filter(|t| matches!(t.role, Role::User))
141 .map(|t| t.text.as_str())
142 .find(|t| !t.is_empty() && !is_system_envelope(t))
143 .map(truncate_title)
144 })
145 .unwrap_or_else(|| "Projected session".to_string());
146
147 let slug = cfg.slug.clone().unwrap_or_else(|| slugify(&title));
148
149 let mut messages: Vec<Message> = Vec::new();
150 let mut prev_msg_id: Option<String> = None;
151 let mut counter: u32 = 0;
152
153 let default_provider = cfg
154 .default_model_provider
155 .clone()
156 .unwrap_or_else(|| DEFAULT_MODEL_PROVIDER.to_string());
157 let default_model = cfg
158 .default_model_id
159 .clone()
160 .unwrap_or_else(|| DEFAULT_MODEL_ID.to_string());
161
162 for turn in &view.turns {
163 match turn.role {
164 Role::User => {
165 let msg = build_user_message(
166 turn,
167 &session_id,
168 &mut counter,
169 &agent,
170 &default_provider,
171 &default_model,
172 );
173 prev_msg_id = Some(msg.id.clone());
174 messages.push(msg);
175 }
176 Role::Assistant => {
177 let parent = prev_msg_id
178 .clone()
179 .unwrap_or_else(|| mint_message_id(&session_id, counter));
180 let msg = build_assistant_message(
181 turn,
182 &session_id,
183 &mut counter,
184 parent,
185 &directory,
186 &agent,
187 &default_provider,
188 &default_model,
189 );
190 prev_msg_id = Some(msg.id.clone());
191 messages.push(msg);
192 }
193 Role::System | Role::Other(_) => {
194 }
199 }
200 }
201
202 Ok(Session {
203 id: session_id,
204 project_id,
205 workspace_id: cfg.workspace_id.clone(),
206 parent_id: None,
207 slug,
208 directory,
209 title,
210 version,
211 share_url: None,
212 summary_additions: None,
213 summary_deletions: None,
214 summary_files: None,
215 time_created,
216 time_updated,
217 time_compacting: None,
218 time_archived: None,
219 messages,
220 })
221}
222
223fn build_user_message(
224 turn: &Turn,
225 session_id: &str,
226 counter: &mut u32,
227 agent: &str,
228 default_provider: &str,
229 default_model: &str,
230) -> Message {
231 *counter += 1;
232 let msg_id = mint_message_id(session_id, *counter);
233 let time_created = parse_timestamp_ms(&turn.timestamp).unwrap_or(0);
234
235 let opencode_extras = opencode_extras(turn);
236 let model = opencode_extras
237 .as_ref()
238 .and_then(|m| m.get("model"))
239 .and_then(|v| serde_json::from_value::<ModelRef>(v.clone()).ok())
240 .unwrap_or_else(|| ModelRef {
241 provider_id: default_provider.to_string(),
242 model_id: default_model.to_string(),
243 variant: None,
244 });
245
246 let user = UserMessage {
247 time: MessageTime {
248 created: time_created,
249 completed: None,
250 },
251 agent: agent.to_string(),
252 model,
253 format: None,
254 summary: Some(crate::types::UserSummary {
255 title: None,
256 body: None,
257 diffs: vec![],
258 extra: HashMap::new(),
259 }),
260 system: None,
261 tools: None,
262 extra: HashMap::new(),
263 };
264
265 let mut parts: Vec<Part> = Vec::new();
266 if !turn.text.is_empty() {
267 *counter += 1;
268 parts.push(Part {
269 id: mint_part_id(session_id, *counter),
270 message_id: msg_id.clone(),
271 session_id: session_id.to_string(),
272 time_created,
273 time_updated: time_created,
274 data: PartData::Text(TextPart {
275 text: turn.text.clone(),
276 synthetic: None,
277 ignored: None,
278 time: None,
279 metadata: None,
280 extra: HashMap::new(),
281 }),
282 });
283 }
284
285 Message {
286 id: msg_id,
287 session_id: session_id.to_string(),
288 time_created,
289 time_updated: time_created,
290 data: MessageData::User(user),
291 parts,
292 }
293}
294
295#[allow(clippy::too_many_arguments)]
296fn build_assistant_message(
297 turn: &Turn,
298 session_id: &str,
299 counter: &mut u32,
300 parent_id: String,
301 cwd: &std::path::Path,
302 agent: &str,
303 default_provider: &str,
304 default_model: &str,
305) -> Message {
306 *counter += 1;
307 let msg_id = mint_message_id(session_id, *counter);
308 let time_created = parse_timestamp_ms(&turn.timestamp).unwrap_or(0);
309
310 let extras = opencode_extras(turn);
311 let provider_id = extras
312 .as_ref()
313 .and_then(|m| m.get("providerID"))
314 .and_then(Value::as_str)
315 .map(str::to_string)
316 .unwrap_or_else(|| default_provider.to_string());
317 let model_id = turn
318 .model
319 .clone()
320 .or_else(|| {
321 extras
322 .as_ref()
323 .and_then(|m| m.get("modelID"))
324 .and_then(Value::as_str)
325 .map(str::to_string)
326 })
327 .unwrap_or_else(|| default_model.to_string());
328
329 let tokens = turn
330 .token_usage
331 .as_ref()
332 .map(|u| {
333 let input = u.input_tokens.unwrap_or(0) as u64;
334 let output = u.output_tokens.unwrap_or(0) as u64;
335 let cache_read = u.cache_read_tokens.unwrap_or(0) as u64;
336 let cache_write = u.cache_write_tokens.unwrap_or(0) as u64;
337 Tokens {
338 total: Some(input + output + cache_read + cache_write),
339 input,
340 output,
341 reasoning: 0,
342 cache: crate::types::TokenCache {
343 read: cache_read,
344 write: cache_write,
345 },
346 }
347 })
348 .unwrap_or_default();
349
350 let assistant = AssistantMessage {
351 parent_id,
352 time: MessageTime {
353 created: time_created,
354 completed: Some(time_created),
355 },
356 error: None,
357 agent: agent.to_string(),
358 mode: Some(agent.to_string()),
360 model_id: model_id.clone(),
361 provider_id: provider_id.clone(),
362 path: MessagePath {
363 cwd: cwd.to_path_buf(),
364 root: cwd.to_path_buf(),
365 },
366 summary: None,
367 cost: 0.0,
368 tokens: tokens.clone(),
369 structured: None,
370 variant: None,
371 finish: turn.stop_reason.clone(),
372 extra: HashMap::new(),
373 };
374
375 let mut parts: Vec<Part> = Vec::new();
376 let snapshot = extras
377 .as_ref()
378 .and_then(|m| m.get("snapshots"))
379 .and_then(Value::as_array)
380 .and_then(|a| a.first())
381 .and_then(Value::as_str)
382 .map(str::to_string);
383
384 *counter += 1;
385 parts.push(Part {
386 id: mint_part_id(session_id, *counter),
387 message_id: msg_id.clone(),
388 session_id: session_id.to_string(),
389 time_created,
390 time_updated: time_created,
391 data: PartData::StepStart(StepStartPart {
392 snapshot: snapshot.clone(),
393 extra: HashMap::new(),
394 }),
395 });
396
397 if let Some(thinking) = &turn.thinking
398 && !thinking.is_empty()
399 {
400 *counter += 1;
401 parts.push(Part {
402 id: mint_part_id(session_id, *counter),
403 message_id: msg_id.clone(),
404 session_id: session_id.to_string(),
405 time_created,
406 time_updated: time_created,
407 data: PartData::Reasoning(ReasoningPart {
408 text: thinking.clone(),
409 time: Some(TimeRange {
410 start: time_created,
411 end: Some(time_created),
412 }),
413 metadata: None,
414 extra: HashMap::new(),
415 }),
416 });
417 }
418
419 if !turn.text.is_empty() {
420 *counter += 1;
421 parts.push(Part {
422 id: mint_part_id(session_id, *counter),
423 message_id: msg_id.clone(),
424 session_id: session_id.to_string(),
425 time_created,
426 time_updated: time_created,
427 data: PartData::Text(TextPart {
428 text: turn.text.clone(),
429 synthetic: None,
430 ignored: None,
431 time: Some(TimeRange {
432 start: time_created,
433 end: Some(time_created),
434 }),
435 metadata: None,
436 extra: HashMap::new(),
437 }),
438 });
439 }
440
441 for tu in &turn.tool_uses {
442 *counter += 1;
443 let part_id = mint_part_id(session_id, *counter);
444 parts.push(Part {
445 id: part_id,
446 message_id: msg_id.clone(),
447 session_id: session_id.to_string(),
448 time_created,
449 time_updated: time_created,
450 data: PartData::Tool(build_tool_part(tu, time_created)),
451 });
452 }
453
454 *counter += 1;
455 parts.push(Part {
456 id: mint_part_id(session_id, *counter),
457 message_id: msg_id.clone(),
458 session_id: session_id.to_string(),
459 time_created,
460 time_updated: time_created,
461 data: PartData::StepFinish(StepFinishPart {
462 reason: turn
463 .stop_reason
464 .clone()
465 .unwrap_or_else(|| "stop".to_string()),
466 snapshot,
467 cost: 0.0,
468 tokens,
469 extra: HashMap::new(),
470 }),
471 });
472
473 Message {
474 id: msg_id,
475 session_id: session_id.to_string(),
476 time_created,
477 time_updated: time_created,
478 data: MessageData::Assistant(assistant),
479 parts,
480 }
481}
482
483fn build_tool_part(tu: &ToolInvocation, time_created: i64) -> ToolPart {
484 let tool_name = native_tool_name(tu);
485 let input = normalize_tool_input(&tool_name, &tu.input);
486 let title = synthesize_title(&tool_name, &input);
487
488 let state = match &tu.result {
489 Some(r) if r.is_error => ToolState::Error(ToolStateError {
490 input,
491 error: r.content.clone(),
492 metadata: None,
493 time: ToolRunTime {
494 start: time_created,
495 end: time_created,
496 compacted: None,
497 },
498 }),
499 Some(r) => {
500 let metadata = synthesize_metadata(&tool_name, &r.content, &input);
501 ToolState::Completed(ToolStateCompleted {
502 input,
503 output: r.content.clone(),
504 title: title.clone(),
505 metadata,
506 time: ToolRunTime {
507 start: time_created,
508 end: time_created,
509 compacted: None,
510 },
511 attachments: None,
512 })
513 }
514 None => ToolState::Completed(ToolStateCompleted {
515 input,
516 output: String::new(),
517 title: title.clone(),
518 metadata: Value::Object(Map::new()),
519 time: ToolRunTime {
520 start: time_created,
521 end: time_created,
522 compacted: None,
523 },
524 attachments: None,
525 }),
526 };
527
528 ToolPart {
529 tool: tool_name,
530 call_id: tu.id.clone(),
531 state,
532 metadata: None,
533 extra: HashMap::new(),
534 }
535}
536
537fn native_tool_name(tu: &ToolInvocation) -> String {
538 if crate::provider::tool_category(&tu.name).is_some() {
539 return tu.name.clone();
540 }
541 if let Some(cat) = tu.category
542 && let Some(remap) = crate::provider::native_name(cat, &tu.input)
543 {
544 return remap.to_string();
545 }
546 tu.name.clone()
547}
548
549fn synthesize_title(tool: &str, input: &Value) -> String {
550 match tool {
551 "bash" => input
552 .get("description")
553 .and_then(Value::as_str)
554 .or_else(|| input.get("command").and_then(Value::as_str))
555 .unwrap_or("")
556 .to_string(),
557 "read" | "edit" | "write" => input
558 .get("filePath")
559 .and_then(Value::as_str)
560 .unwrap_or("")
561 .to_string(),
562 "grep" => input
563 .get("pattern")
564 .and_then(Value::as_str)
565 .unwrap_or("")
566 .to_string(),
567 "glob" => input
568 .get("pattern")
569 .or_else(|| input.get("path"))
570 .and_then(Value::as_str)
571 .unwrap_or("")
572 .to_string(),
573 _ => String::new(),
574 }
575}
576
577fn normalize_tool_input(tool: &str, input: &Value) -> Value {
581 let Some(obj) = input.as_object() else {
582 return input.clone();
583 };
584 let rename = |obj: &mut Map<String, Value>, from: &str, to: &str| {
585 if obj.contains_key(to) {
586 return;
587 }
588 if let Some(v) = obj.remove(from) {
589 obj.insert(to.to_string(), v);
590 }
591 };
592 let mut out = obj.clone();
593 match tool {
594 "read" => {
595 rename(&mut out, "file_path", "filePath");
596 }
597 "write" => {
598 rename(&mut out, "file_path", "filePath");
599 }
600 "edit" => {
601 rename(&mut out, "file_path", "filePath");
602 rename(&mut out, "old_string", "oldString");
603 rename(&mut out, "new_string", "newString");
604 }
605 _ => {}
606 }
607 Value::Object(out)
608}
609
610fn synthesize_metadata(tool: &str, output: &str, input: &Value) -> Value {
611 let mut m = Map::new();
612 match tool {
613 "bash" => {
614 m.insert("output".to_string(), Value::String(output.to_string()));
615 m.insert("exit".to_string(), Value::Number(0.into()));
616 m.insert("truncated".to_string(), Value::Bool(false));
617 }
618 "edit" => {
619 m.insert("diagnostics".to_string(), Value::Object(Map::new()));
620 if let Some(diff) = synthesize_edit_diff(input) {
621 m.insert("diff".to_string(), Value::String(diff));
622 }
623 }
624 "write" => {
625 m.insert("diagnostics".to_string(), Value::Object(Map::new()));
626 }
627 "read" => {
628 m.insert("diagnostics".to_string(), Value::Object(Map::new()));
629 }
630 _ => {}
631 }
632 Value::Object(m)
633}
634
635fn synthesize_edit_diff(input: &Value) -> Option<String> {
636 let path = input.get("filePath").and_then(Value::as_str)?;
637 let old = input.get("oldString").and_then(Value::as_str)?;
638 let new_s = input.get("newString").and_then(Value::as_str)?;
639 let old_lines: Vec<&str> = if old.is_empty() {
640 vec![]
641 } else {
642 old.split('\n').collect()
643 };
644 let new_lines: Vec<&str> = if new_s.is_empty() {
645 vec![]
646 } else {
647 new_s.split('\n').collect()
648 };
649 let old_count = old_lines.len();
650 let new_count = new_lines.len();
651 let old_start = if old_count == 0 { 0 } else { 1 };
652 let new_start = if new_count == 0 { 0 } else { 1 };
653 let mut out = String::new();
654 out.push_str(&format!("Index: {}\n", path));
655 out.push_str("===================================================================\n");
656 out.push_str(&format!("--- {}\n", path));
657 out.push_str(&format!("+++ {}\n", path));
658 out.push_str(&format!(
659 "@@ -{},{} +{},{} @@\n",
660 old_start, old_count, new_start, new_count
661 ));
662 for line in &old_lines {
663 out.push_str(&format!("-{}\n", line));
664 }
665 for line in &new_lines {
666 out.push_str(&format!("+{}\n", line));
667 }
668 Some(out)
669}
670
671fn opencode_extras(_turn: &Turn) -> Option<&'static Map<String, Value>> {
672 None
673}
674
675fn mint_session_id(seed: &str) -> String {
676 format!("ses_{}", stable_hex24(seed))
677}
678
679fn mint_message_id(session_id: &str, n: u32) -> String {
680 format!("msg_{}", stable_hex24(&format!("{}-msg-{}", session_id, n)))
681}
682
683fn mint_part_id(session_id: &str, n: u32) -> String {
684 format!("prt_{}", stable_hex24(&format!("{}-prt-{}", session_id, n)))
685}
686
687fn stable_hex24(seed: &str) -> String {
688 use sha1::{Digest, Sha1};
689 let mut h = Sha1::new();
690 h.update(seed.as_bytes());
691 let bytes = h.finalize();
692 hex::encode(&bytes[..12])
693}
694
695fn parse_timestamp_ms(ts: &str) -> Option<i64> {
696 chrono::DateTime::parse_from_rfc3339(ts)
697 .ok()
698 .map(|dt| dt.timestamp_millis())
699}
700
701fn truncate_title(text: &str) -> String {
702 let trimmed = text.trim();
703 let first_line = trimmed.lines().next().unwrap_or("").trim();
704 first_line.chars().take(120).collect()
705}
706
707fn is_system_envelope(text: &str) -> bool {
708 let trimmed = text.trim_start();
709 trimmed.starts_with('<') && trimmed.contains('>')
710}
711
712fn slugify(title: &str) -> String {
713 let mut out = String::new();
714 let mut last_dash = true;
715 for c in title.chars().take(60) {
716 if c.is_ascii_alphanumeric() {
717 out.push(c.to_ascii_lowercase());
718 last_dash = false;
719 } else if !last_dash {
720 out.push('-');
721 last_dash = true;
722 }
723 }
724 let trimmed = out.trim_matches('-').to_string();
725 if trimmed.is_empty() {
726 "session".to_string()
727 } else {
728 trimmed
729 }
730}
731
732fn derive_project_id(directory: &std::path::Path) -> String {
737 stable_hex40(directory.to_string_lossy().as_bytes())
738}
739
740fn stable_hex40(seed: &[u8]) -> String {
741 use sha1::{Digest, Sha1};
742 let mut h = Sha1::new();
743 h.update(seed);
744 hex::encode(h.finalize())
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750 use serde_json::json;
751 use toolpath_convo::{ToolCategory, ToolInvocation, ToolResult};
752
753 fn user_turn(text: &str) -> Turn {
754 Turn {
755 id: "u1".into(),
756 parent_id: None,
757 role: Role::User,
758 timestamp: "2026-04-21T12:00:00.000Z".into(),
759 text: text.into(),
760 thinking: None,
761 tool_uses: vec![],
762 model: None,
763 stop_reason: None,
764 token_usage: None,
765 environment: None,
766 delegations: vec![],
767 file_mutations: Vec::new(),
768 }
769 }
770
771 fn assistant_turn(text: &str) -> Turn {
772 Turn {
773 id: "a1".into(),
774 parent_id: None,
775 role: Role::Assistant,
776 timestamp: "2026-04-21T12:00:01.000Z".into(),
777 text: text.into(),
778 thinking: None,
779 tool_uses: vec![],
780 model: Some("claude-sonnet-4-6".into()),
781 stop_reason: Some("stop".into()),
782 token_usage: None,
783 environment: None,
784 delegations: vec![],
785 file_mutations: Vec::new(),
786 }
787 }
788
789 fn view_with(turns: Vec<Turn>) -> ConversationView {
790 ConversationView {
791 id: "session-uuid".into(),
792 started_at: None,
793 last_activity: None,
794 turns,
795 total_usage: None,
796 provider_id: Some("opencode".into()),
797 files_changed: vec![],
798 session_ids: vec![],
799 events: vec![],
800 ..Default::default()
801 }
802 }
803
804 #[test]
805 fn empty_view_yields_session_with_no_messages() {
806 let s = OpencodeProjector::default()
807 .project(&view_with(vec![]))
808 .unwrap();
809 assert!(s.id.starts_with("ses_"));
810 assert!(s.messages.is_empty());
811 }
812
813 #[test]
814 fn user_turn_becomes_user_message_with_text_part() {
815 let s = OpencodeProjector::default()
816 .project(&view_with(vec![user_turn("hello")]))
817 .unwrap();
818 assert_eq!(s.messages.len(), 1);
819 let m = &s.messages[0];
820 assert!(m.id.starts_with("msg_"));
821 assert!(matches!(m.data, MessageData::User(_)));
822 assert_eq!(m.parts.len(), 1);
823 match &m.parts[0].data {
824 PartData::Text(t) => assert_eq!(t.text, "hello"),
825 _ => panic!("expected Text"),
826 }
827 }
828
829 #[test]
830 fn assistant_turn_emits_step_start_text_step_finish() {
831 let s = OpencodeProjector::default()
832 .project(&view_with(vec![assistant_turn("done")]))
833 .unwrap();
834 assert_eq!(s.messages.len(), 1);
835 let kinds: Vec<&str> = s.messages[0].parts.iter().map(|p| p.data.kind()).collect();
836 assert_eq!(kinds, vec!["step-start", "text", "step-finish"]);
837 }
838
839 #[test]
840 fn tool_call_lands_as_tool_part() {
841 let mut t = assistant_turn("");
842 t.tool_uses = vec![ToolInvocation {
843 id: "call_x".into(),
844 name: "Bash".into(),
845 input: json!({"command": "ls"}),
846 result: Some(ToolResult {
847 content: "out\n".into(),
848 is_error: false,
849 }),
850 category: Some(ToolCategory::Shell),
851 }];
852 let s = OpencodeProjector::default()
853 .project(&view_with(vec![t]))
854 .unwrap();
855 let parts = &s.messages[0].parts;
856 let tool_part = parts
857 .iter()
858 .find(|p| matches!(p.data, PartData::Tool(_)))
859 .expect("tool part");
860 match &tool_part.data {
861 PartData::Tool(tp) => {
862 assert_eq!(tp.tool, "bash");
863 assert_eq!(tp.call_id, "call_x");
864 match &tp.state {
865 ToolState::Completed(c) => {
866 assert_eq!(c.output, "out\n");
867 assert_eq!(c.input["command"], "ls");
868 assert_eq!(c.metadata["exit"], 0);
869 }
870 _ => panic!("expected completed state"),
871 }
872 }
873 _ => panic!("expected tool part"),
874 }
875 }
876
877 #[test]
878 fn errored_tool_use_produces_error_state() {
879 let mut t = assistant_turn("");
880 t.tool_uses = vec![ToolInvocation {
881 id: "c".into(),
882 name: "bash".into(),
883 input: json!({"command": "false"}),
884 result: Some(ToolResult {
885 content: "exit 1".into(),
886 is_error: true,
887 }),
888 category: Some(ToolCategory::Shell),
889 }];
890 let s = OpencodeProjector::default()
891 .project(&view_with(vec![t]))
892 .unwrap();
893 let tp = s.messages[0]
894 .parts
895 .iter()
896 .find_map(|p| match &p.data {
897 PartData::Tool(tp) => Some(tp),
898 _ => None,
899 })
900 .unwrap();
901 assert!(matches!(tp.state, ToolState::Error(_)));
902 }
903
904 #[test]
905 fn foreign_tool_name_remaps_via_category() {
906 let mut t = assistant_turn("");
907 t.tool_uses = vec![ToolInvocation {
908 id: "c".into(),
909 name: "Edit".into(),
910 input: json!({"file_path": "x.rs", "old_string": "a", "new_string": "b"}),
911 result: None,
912 category: Some(ToolCategory::FileWrite),
913 }];
914 let s = OpencodeProjector::default()
915 .project(&view_with(vec![t]))
916 .unwrap();
917 let tp = s.messages[0]
918 .parts
919 .iter()
920 .find_map(|p| match &p.data {
921 PartData::Tool(tp) => Some(tp),
922 _ => None,
923 })
924 .unwrap();
925 assert_eq!(tp.tool, "edit");
926 }
927
928 #[test]
929 fn assistant_thinking_emits_reasoning_part() {
930 let mut t = assistant_turn("ok");
931 t.thinking = Some("considering options".into());
932 let s = OpencodeProjector::default()
933 .project(&view_with(vec![t]))
934 .unwrap();
935 let kinds: Vec<&str> = s.messages[0].parts.iter().map(|p| p.data.kind()).collect();
936 assert_eq!(
937 kinds,
938 vec!["step-start", "reasoning", "text", "step-finish"]
939 );
940 }
941
942 #[test]
943 fn assistant_parent_id_chains_to_prior_user_message() {
944 let s = OpencodeProjector::default()
945 .project(&view_with(vec![user_turn("hi"), assistant_turn("ok")]))
946 .unwrap();
947 let user_id = s.messages[0].id.clone();
948 match &s.messages[1].data {
949 MessageData::Assistant(a) => assert_eq!(a.parent_id, user_id),
950 _ => panic!("expected assistant"),
951 }
952 }
953}