blob: 873bda6078bf2cdae6105c8d57b0e8a277c1882a [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use maka_agent_facts::import::Content;
use maka_session_import::opencode::{self, Message, Part, Session, Snapshot};
use serde_json::{Value, json, value::to_raw_value};
fn message(id: &str, at: u64, data: Value) -> Message {
Message {
id: id.into(),
created_at: Some(at),
data: to_raw_value(&data).unwrap(),
}
}
fn part(id: &str, message: &str, at: u64, data: Value) -> Part {
Part {
id: format!("{at:03}-{id}"),
message_id: message.into(),
created_at: Some(at),
data: to_raw_value(&data).unwrap(),
}
}
fn snapshot() -> Snapshot {
Snapshot {
session: Session {
id: "s".into(),
parent_id: None,
directory: Some("/source/project".into()),
title: None,
revert: None,
},
messages: vec![
message(
"answer",
20,
json!({"role":"assistant","modelID":"source-model","finish":"tool-calls"}),
),
message("prompt", 10, json!({"role":"user"})),
],
parts: vec![
part(
"last",
"answer",
24,
json!({"type":"tool","callID":"reused","tool":"shell","state":{"status":"running","input":{"command":"still pending"}}}),
),
part(
"prompt-text",
"prompt",
10,
json!({"type":"text","text":"Read the file"}),
),
part(
"answer-text",
"answer",
20,
json!({"type":"text","text":"Before thinking"}),
),
part(
"answer-thinking",
"answer",
21,
json!({"type":"reasoning","text":"Then thinking"}),
),
part(
"first",
"answer",
22,
json!({"type":"tool","callID":"reused","tool":"Read","state":{"status":"completed","input":{"path":"a"},"output":"one\ntwo\n"}}),
),
part(
"failure",
"answer",
23,
json!({"type":"tool","callID":"reused","tool":"Read","state":{"status":"error","input":{"path":"b"},"error":"source failure"}}),
),
],
}
}
#[test]
fn opencode_preserves_part_order_tool_pairing_and_missing_results_without_native_execution() {
let mut source = snapshot();
// Source part IDs, not insertion timestamps, determine displayed order.
for part in &mut source.parts {
part.created_at = part.created_at.map(|time| 100 - time);
}
let transcript = opencode::convert(source, "s").unwrap();
assert_eq!(transcript.title, "Read the file");
assert_eq!(transcript.cwd.as_deref(), Some("/source/project"));
assert!(!transcript.fingerprint.incomplete_tail);
let records = transcript.records;
assert!(
records
.iter()
.all(|record| record.source_turn_id == "prompt")
);
assert!(matches!(&records[0].content, Content::User { text } if text == "Read the file"));
assert!(
matches!(&records[1].content, Content::Assistant { text, thinking: None, model: Some(model) } if text == "Before thinking" && model == "source-model")
);
assert!(
matches!(&records[2].content, Content::Assistant { text, thinking: Some(thinking), .. } if text.is_empty() && thinking == "Then thinking")
);
let calls: Vec<_> = records
.iter()
.filter_map(|record| match &record.content {
Content::ToolCall { call_id, .. } => Some(call_id),
_ => None,
})
.collect();
let results: Vec<_> = records
.iter()
.filter_map(|record| match &record.content {
Content::ToolResult {
call_id,
output,
is_error,
} => Some((call_id, output, *is_error)),
_ => None,
})
.collect();
assert_eq!(calls.len(), 3);
assert_eq!(
results,
[
(calls[0], &json!("one\ntwo\n"), false),
(calls[1], &json!("source failure"), true)
]
);
assert_ne!(calls[0], calls[1]);
assert_ne!(calls[1], calls[2]);
assert!(
matches!(&records.last().unwrap().content, Content::Note { text } if text == "Source finish: tool-calls")
);
let mut partial = snapshot();
partial.session.revert = Some(opencode::Revert {
message_id: "answer".into(),
part_id: Some("022-first".into()),
});
for part in &mut partial.parts {
part.created_at = part.created_at.map(|time| 100 - time);
}
let partial = opencode::convert(partial, "s").unwrap();
assert!(!partial.records.iter().any(|record| matches!(
record.content,
Content::ToolCall { .. } | Content::ToolResult { .. }
)));
assert!(
partial
.records
.iter()
.any(|record| matches!(&record.content,
Content::Assistant { text, .. } if text == "Before thinking"))
);
let mut reverted = snapshot();
reverted.session.revert = Some(opencode::Revert {
message_id: "answer".into(),
part_id: None,
});
let reverted = opencode::convert(reverted, "s").unwrap();
assert_eq!(reverted.records.len(), 1);
assert!(matches!(reverted.records[0].content, Content::User { .. }));
}
#[test]
fn opencode_refuses_ambiguous_or_corrupt_snapshots_before_publishing_any_history() {
let mut unknown_revert = snapshot();
unknown_revert.session.revert = Some(opencode::Revert {
message_id: "missing".into(),
part_id: None,
});
assert!(opencode::convert(unknown_revert, "s").is_err());
let mut invalid_role = snapshot();
invalid_role.messages[0].data = to_raw_value(&json!({"role":"asssistant"})).unwrap();
assert!(opencode::convert(invalid_role, "s").is_err());
assert!(opencode::convert(snapshot(), "wrong").is_err());
let mut child = snapshot();
child.session.parent_id = Some("parent".into());
assert!(opencode::convert(child, "s").is_err());
let mut orphan = snapshot();
orphan.parts[0].message_id = "missing".into();
assert!(opencode::convert(orphan, "s").is_err());
let mut broken = snapshot();
broken.parts[0].data = to_raw_value(
&json!({"type":"tool","tool":"Read","callID":"c","state":{"status":"completed"}}),
)
.unwrap();
assert!(opencode::convert(broken, "s").is_err());
let mut dense = snapshot();
dense.parts[0].data = to_raw_value(&json!({"type":"tool","tool":"Read","callID":"c","state":{"status":"running","input":vec![0;70_000]}})).unwrap();
assert!(opencode::convert(dense, "s").is_ok());
}