Skip to main content

toolpath_opencode/
project.rs

1//! [`OpencodeProjector`] — maps a [`ConversationView`] back to an
2//! opencode [`Session`] (with messages and parts populated).
3
4use 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                // opencode has no system-role message variant; fold the
195                // text into the next user/assistant turn's context by
196                // skipping. The system prompt itself rides on
197                // UserMessage.system if needed.
198            }
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        // Required by opencode's schema (deprecated but still validated).
359        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
577/// Map Claude/Codex-shape arg keys onto opencode's camelCase vocabulary
578/// — opencode's TUI reads `filePath`, `oldString`, `newString` and shows
579/// "preparing edit..." when those are missing.
580fn 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
732/// Best-effort fallback when the caller hasn't supplied a project_id.
733/// Real opencode keys this off the first-root-commit SHA; for projected
734/// sessions we use the SHA-1 of the worktree path as a stable
735/// substitute.
736fn 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}