thirdshift 0.10.0

Turn a GitHub issue into a ready-for-review pull request, unattended
//! Read OpenCode 2's standalone export, whose assistant content and usage
//! survive dropped stream events. Session totals also count the title call.
use super::OpenCode;
use crate::harness::Adapter;
use anyhow::{Result, anyhow};
use serde_json::Value;
use std::path::Path;
use std::process::Command;

pub(in crate::harness) struct SessionExport {
    pub(in crate::harness) message: Option<String>,
    pub(in crate::harness) summary: Option<String>,
    pub(in crate::harness) failure: Option<String>,
}

/// OpenCode records uncached input and visible output separately from cache
/// and reasoning. Combine them into totals while retaining each subtotal.
#[derive(Default)]
struct Usage {
    input: u64,
    cache_read: u64,
    cache_write: u64,
    output: u64,
    reasoning: u64,
    cost: Option<f64>,
}

impl Usage {
    fn add(&mut self, message: &Value) {
        let tokens = &message["tokens"];
        self.input += tokens["input"].as_u64().unwrap_or(0);
        self.cache_read += tokens["cache"]["read"].as_u64().unwrap_or(0);
        self.cache_write += tokens["cache"]["write"].as_u64().unwrap_or(0);
        self.output += tokens["output"].as_u64().unwrap_or(0);
        self.reasoning += tokens["reasoning"].as_u64().unwrap_or(0);
        if let Some(cost) = message["cost"].as_f64() {
            *self.cost.get_or_insert(0.0) += cost;
        }
    }
    fn summary(&self) -> String {
        let input = self.input + self.cache_read + self.cache_write;
        let output = self.output + self.reasoning;
        let cost = self
            .cost
            .map(|cost| format!(", ${cost:.4}"))
            .unwrap_or_default();
        format!(
            "{input} input tokens ({} cache read, {} cache write), {output} output tokens ({} reasoning){cost}",
            self.cache_read, self.cache_write, self.reasoning
        )
    }
}

impl SessionExport {
    pub fn parse(text: &str) -> Result<Self> {
        let export: Value = serde_json::from_str(text)?;
        let outcome = export["info"]["outcome"]
            .as_str()
            .ok_or_else(|| anyhow!("no OpenCode outcome in session export"))?;
        let messages = export["messages"]
            .as_array()
            .ok_or_else(|| anyhow!("no messages in OpenCode session export"))?;
        let mut result = Self {
            message: None,
            summary: None,
            failure: None,
        };
        let mut usage: Option<Usage> = None;
        for message in messages
            .iter()
            .filter(|message| message["type"] == "assistant")
        {
            result.message = message["content"]
                .as_array()
                .and_then(|parts| parts.iter().rev().find(|part| part["type"] == "text"))
                .and_then(|part| part["text"].as_str())
                .map(String::from);
            if message["tokens"].is_object() {
                usage.get_or_insert_default().add(message);
            }
            if let Some(error) = super::error_text(&message["error"]) {
                result.failure = Some(error.into());
            }
        }
        result.summary = usage.as_ref().map(Usage::summary);
        if outcome == "failed" {
            result.failure = Some(
                super::error_text(&export["info"]["error"])
                    .map(String::from)
                    .or(result.failure)
                    .unwrap_or_else(|| "OpenCode's exported outcome is failed".into()),
            );
        } else {
            result.failure = None;
        }
        Ok(result)
    }
}

pub(in crate::harness) fn read(worktree: &Path, id: &str) -> Result<Option<SessionExport>> {
    let output = super::super::process::output(
        &OpenCode,
        Command::new(OpenCode.name())
            .args(["session", "export", "--standalone", id])
            .current_dir(worktree),
        None,
    );
    match output {
        Ok(output) if output.status.success() => {
            Ok(SessionExport::parse(&String::from_utf8_lossy(&output.stdout)).ok())
        }
        Err(error) if crate::interrupt::requested() => Err(error),
        _ => Ok(None),
    }
}