fix: parse tool_use/tool_result messages and add OpenCode SQLite backend (#1401)

* fix: parse tool_use/tool_result messages and add OpenCode SQLite backend

  - Claude: reclassify user messages containing tool_result as "tool" role
  - Codex: handle function_call and function_call_output payload types
  - Gemini: support array content and toolCalls extraction, filter info/error types
  - OpenCode: add SQLite session scan, load and delete alongside legacy JSON
  - utils: extend parse_timestamp_to_ms for integer timestamps, extract tool_use/tool_result in shared extract_text

* fix: address remaining issues from tool_use/tool_result parsing commit
  - Claude: fix role misclassification for mixed user+tool_result messages (any → all)
  - OpenCode: extract duplicate part text logic into extract_part_text()
  - OpenCode: add path validation for SQLite delete to prevent foreign DB access
  - OpenCode: wrap SQLite deletion in transaction for atomicity
  - openclaw_config: remove redundant as_deref() on Option<&str>
This commit is contained in:
BlueOcean
2026-03-22 22:02:35 +08:00
committed by GitHub
parent 117dbf1386
commit bd3cfb7741
7 changed files with 858 additions and 35 deletions
@@ -52,11 +52,25 @@ pub fn load_messages(path: &Path) -> Result<Vec<SessionMessage>, String> {
None => continue,
};
let role = message
let mut role = message
.get("role")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
// Claude wraps tool_result inside user messages; reclassify as "tool" role
if role == "user" {
if let Some(Value::Array(items)) = message.get("content") {
let all_tool_results = !items.is_empty()
&& items.iter().all(|item| {
item.get("type").and_then(Value::as_str) == Some("tool_result")
});
if all_tool_results {
role = "tool".to_string();
}
}
}
let content = message.get("content").map(extract_text).unwrap_or_default();
if content.trim().is_empty() {
continue;
@@ -268,4 +282,58 @@ mod tests {
assert!(!path.exists());
assert!(!sidecar.exists());
}
#[test]
fn load_messages_tool_use_shows_as_assistant() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
concat!(
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Write\",\"input\":{\"file_path\":\"a.txt\"}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"File written\"}]},\"timestamp\":\"2026-03-06T10:00:01Z\"}\n",
),
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("[Tool: Write]"));
assert_eq!(msgs[1].role, "tool");
assert_eq!(msgs[1].content, "File written");
}
#[test]
fn load_messages_mixed_text_and_tool_use() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Let me help.\"},{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Read\",\"input\":{}}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "assistant");
assert!(msgs[0].content.contains("Let me help."));
assert!(msgs[0].content.contains("[Tool: Read]"));
}
#[test]
fn load_messages_mixed_user_tool_result_and_text_stays_user() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("session.jsonl");
std::fs::write(
&path,
"{\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"result\"},{\"type\":\"text\",\"text\":\"Please continue\"}]},\"timestamp\":\"2026-03-06T10:00:00Z\"}\n",
)
.expect("write");
let msgs = load_messages(&path).expect("load");
assert_eq!(msgs.len(), 1);
assert_eq!(msgs[0].role, "user");
assert!(msgs[0].content.contains("Please continue"));
}
}