Skip to content

Commit 7a2372d

Browse files
committed
feat(agents): improve session ID extraction and JSONL file handling
- Fix session ID extraction to use correct field name "session_id" instead of "sessionId" - Add comprehensive database update logging with error handling - Implement cross-project session file search in get_session_output - Add new load_agent_session_history command for robust JSONL loading - Update UI components to prioritize JSONL file loading over fallback methods - Improve error handling and logging throughout the session management flow - Fix BufReader imports and alias conflicts in Tauri backend This enhances the reliability of agent session tracking and output retrieval by properly handling Claude Code's actual JSON structure and implementing better fallback mechanisms for session data access.
1 parent 9eeb336 commit 7a2372d

6 files changed

Lines changed: 320 additions & 29 deletions

File tree

src-tauri/src/commands/agents.rs

Lines changed: 181 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
use anyhow::Result;
22
use chrono;
3+
use dirs;
34
use log::{debug, error, info, warn};
5+
use regex;
46
use reqwest;
57
use rusqlite::{params, Connection, Result as SqliteResult};
68
use serde::{Deserialize, Serialize};
79
use serde_json::Value as JsonValue;
10+
use std::io::{BufRead, BufReader};
811
use std::process::Stdio;
912
use std::sync::{Arc, Mutex};
1013
use tauri::{AppHandle, Emitter, Manager, State};
1114
use tauri_plugin_shell::ShellExt;
12-
use tokio::io::{AsyncBufReadExt, BufReader};
15+
use tokio::io::{AsyncBufReadExt, BufReader as TokioBufReader};
1316
use tokio::process::Command;
14-
use regex;
1517

1618
/// Finds the full path to the claude binary
1719
/// This is necessary because macOS apps have a limited PATH environment
@@ -855,11 +857,15 @@ async fn spawn_agent_sidecar(
855857

856858
// Extract session ID from JSONL output
857859
if let Ok(json) = serde_json::from_str::<JsonValue>(&line) {
858-
if let Some(sid) = json.get("sessionId").and_then(|s| s.as_str()) {
859-
if let Ok(mut current_session_id) = session_id_holder_clone.lock() {
860-
if current_session_id.is_none() {
861-
*current_session_id = Some(sid.to_string());
862-
info!("🔑 Extracted session ID: {}", sid);
860+
// Claude Code uses "session_id" (underscore), not "sessionId"
861+
if json.get("type").and_then(|t| t.as_str()) == Some("system") &&
862+
json.get("subtype").and_then(|s| s.as_str()) == Some("init") {
863+
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
864+
if let Ok(mut current_session_id) = session_id_holder_clone.lock() {
865+
if current_session_id.is_none() {
866+
*current_session_id = Some(sid.to_string());
867+
info!("🔑 Extracted session ID: {}", sid);
868+
}
863869
}
864870
}
865871
}
@@ -955,10 +961,24 @@ async fn spawn_agent_sidecar(
955961

956962
// Update the run record with session ID and mark as completed
957963
if let Ok(conn) = Connection::open(&db_path) {
958-
let _ = conn.execute(
964+
info!("🔄 Updating database with extracted session ID: {}", extracted_session_id);
965+
match conn.execute(
959966
"UPDATE agent_runs SET session_id = ?1, status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
960967
params![extracted_session_id, run_id],
961-
);
968+
) {
969+
Ok(rows_affected) => {
970+
if rows_affected > 0 {
971+
info!("✅ Successfully updated agent run {} with session ID: {}", run_id, extracted_session_id);
972+
} else {
973+
warn!("⚠️ No rows affected when updating agent run {} with session ID", run_id);
974+
}
975+
}
976+
Err(e) => {
977+
error!("❌ Failed to update agent run {} with session ID: {}", run_id, e);
978+
}
979+
}
980+
} else {
981+
error!("❌ Failed to open database to update session ID for run {}", run_id);
962982
}
963983

964984
info!("✅ Claude sidecar execution monitoring complete");
@@ -1017,8 +1037,8 @@ async fn spawn_agent_system(
10171037
info!("📡 Set up stdout/stderr readers");
10181038

10191039
// Create readers
1020-
let stdout_reader = BufReader::new(stdout);
1021-
let stderr_reader = BufReader::new(stderr);
1040+
let stdout_reader = TokioBufReader::new(stdout);
1041+
let stderr_reader = TokioBufReader::new(stderr);
10221042

10231043
// Shared state for collecting session ID and live output
10241044
let session_id = std::sync::Arc::new(Mutex::new(String::new()));
@@ -1067,11 +1087,15 @@ async fn spawn_agent_system(
10671087

10681088
// Extract session ID from JSONL output
10691089
if let Ok(json) = serde_json::from_str::<JsonValue>(&line) {
1070-
if let Some(sid) = json.get("sessionId").and_then(|s| s.as_str()) {
1071-
if let Ok(mut current_session_id) = session_id_clone.lock() {
1072-
if current_session_id.is_empty() {
1073-
*current_session_id = sid.to_string();
1074-
info!("🔑 Extracted session ID: {}", sid);
1090+
// Claude Code uses "session_id" (underscore), not "sessionId"
1091+
if json.get("type").and_then(|t| t.as_str()) == Some("system") &&
1092+
json.get("subtype").and_then(|s| s.as_str()) == Some("init") {
1093+
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
1094+
if let Ok(mut current_session_id) = session_id_clone.lock() {
1095+
if current_session_id.is_empty() {
1096+
*current_session_id = sid.to_string();
1097+
info!("🔑 Extracted session ID: {}", sid);
1098+
}
10751099
}
10761100
}
10771101
}
@@ -1232,10 +1256,24 @@ async fn spawn_agent_system(
12321256

12331257
// Update the run record with session ID and mark as completed - open a new connection
12341258
if let Ok(conn) = Connection::open(&db_path) {
1235-
let _ = conn.execute(
1259+
info!("🔄 Updating database with extracted session ID: {}", extracted_session_id);
1260+
match conn.execute(
12361261
"UPDATE agent_runs SET session_id = ?1, status = 'completed', completed_at = CURRENT_TIMESTAMP WHERE id = ?2",
12371262
params![extracted_session_id, run_id],
1238-
);
1263+
) {
1264+
Ok(rows_affected) => {
1265+
if rows_affected > 0 {
1266+
info!("✅ Successfully updated agent run {} with session ID: {}", run_id, extracted_session_id);
1267+
} else {
1268+
warn!("⚠️ No rows affected when updating agent run {} with session ID", run_id);
1269+
}
1270+
}
1271+
Err(e) => {
1272+
error!("❌ Failed to update agent run {} with session ID: {}", run_id, e);
1273+
}
1274+
}
1275+
} else {
1276+
error!("❌ Failed to open database to update session ID for run {}", run_id);
12391277
}
12401278

12411279
// Cleanup will be handled by the cleanup_finished_processes function
@@ -1484,13 +1522,66 @@ pub async fn get_session_output(
14841522
return Ok(String::new());
14851523
}
14861524

1487-
// Read the JSONL content
1488-
match read_session_jsonl(&run.session_id, &run.project_path).await {
1489-
Ok(content) => Ok(content),
1490-
Err(_) => {
1491-
// Fallback to live output if JSONL file doesn't exist yet
1492-
let live_output = registry.0.get_live_output(run_id)?;
1493-
Ok(live_output)
1525+
// Get the Claude directory
1526+
let claude_dir = dirs::home_dir()
1527+
.ok_or("Failed to get home directory")?
1528+
.join(".claude");
1529+
1530+
// Find the correct project directory by searching for the session file
1531+
let projects_dir = claude_dir.join("projects");
1532+
1533+
// Check if projects directory exists
1534+
if !projects_dir.exists() {
1535+
log::error!("Projects directory not found at: {:?}", projects_dir);
1536+
return Err("Projects directory not found".to_string());
1537+
}
1538+
1539+
// Search for the session file in all project directories
1540+
let mut session_file_path = None;
1541+
log::info!("Searching for session file {} in all project directories", run.session_id);
1542+
1543+
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
1544+
for entry in entries.filter_map(Result::ok) {
1545+
let path = entry.path();
1546+
if path.is_dir() {
1547+
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
1548+
log::debug!("Checking project directory: {}", dir_name);
1549+
1550+
let potential_session_file = path.join(format!("{}.jsonl", run.session_id));
1551+
if potential_session_file.exists() {
1552+
log::info!("Found session file at: {:?}", potential_session_file);
1553+
session_file_path = Some(potential_session_file);
1554+
break;
1555+
} else {
1556+
log::debug!("Session file not found in: {}", dir_name);
1557+
}
1558+
}
1559+
}
1560+
} else {
1561+
log::error!("Failed to read projects directory");
1562+
}
1563+
1564+
// If we found the session file, read it
1565+
if let Some(session_path) = session_file_path {
1566+
match tokio::fs::read_to_string(&session_path).await {
1567+
Ok(content) => Ok(content),
1568+
Err(e) => {
1569+
log::error!("Failed to read session file {}: {}", session_path.display(), e);
1570+
// Fallback to live output if file read fails
1571+
let live_output = registry.0.get_live_output(run_id)?;
1572+
Ok(live_output)
1573+
}
1574+
}
1575+
} else {
1576+
// If session file not found, try the old method as fallback
1577+
log::warn!("Session file not found for {}, trying legacy method", run.session_id);
1578+
match read_session_jsonl(&run.session_id, &run.project_path).await {
1579+
Ok(content) => Ok(content),
1580+
Err(_) => {
1581+
// Final fallback to live output
1582+
let live_output = registry.0.get_live_output(run_id)?;
1583+
Ok(live_output)
1584+
}
14941585
}
14951586
}
14961587
}
@@ -2076,3 +2167,68 @@ pub async fn import_agent_from_github(
20762167
// Import using existing function
20772168
import_agent(db, json_data).await
20782169
}
2170+
2171+
/// Load agent session history from JSONL file
2172+
/// Similar to Claude Code's load_session_history, but searches across all project directories
2173+
#[tauri::command]
2174+
pub async fn load_agent_session_history(
2175+
session_id: String,
2176+
) -> Result<Vec<serde_json::Value>, String> {
2177+
log::info!("Loading agent session history for session: {}", session_id);
2178+
2179+
let claude_dir = dirs::home_dir()
2180+
.ok_or("Failed to get home directory")?
2181+
.join(".claude");
2182+
2183+
let projects_dir = claude_dir.join("projects");
2184+
2185+
if !projects_dir.exists() {
2186+
log::error!("Projects directory not found at: {:?}", projects_dir);
2187+
return Err("Projects directory not found".to_string());
2188+
}
2189+
2190+
// Search for the session file in all project directories
2191+
let mut session_file_path = None;
2192+
log::info!("Searching for session file {} in all project directories", session_id);
2193+
2194+
if let Ok(entries) = std::fs::read_dir(&projects_dir) {
2195+
for entry in entries.filter_map(Result::ok) {
2196+
let path = entry.path();
2197+
if path.is_dir() {
2198+
let dir_name = path.file_name().unwrap_or_default().to_string_lossy();
2199+
log::debug!("Checking project directory: {}", dir_name);
2200+
2201+
let potential_session_file = path.join(format!("{}.jsonl", session_id));
2202+
if potential_session_file.exists() {
2203+
log::info!("Found session file at: {:?}", potential_session_file);
2204+
session_file_path = Some(potential_session_file);
2205+
break;
2206+
} else {
2207+
log::debug!("Session file not found in: {}", dir_name);
2208+
}
2209+
}
2210+
}
2211+
} else {
2212+
log::error!("Failed to read projects directory");
2213+
}
2214+
2215+
if let Some(session_path) = session_file_path {
2216+
let file = std::fs::File::open(&session_path)
2217+
.map_err(|e| format!("Failed to open session file: {}", e))?;
2218+
2219+
let reader = BufReader::new(file);
2220+
let mut messages = Vec::new();
2221+
2222+
for line in reader.lines() {
2223+
if let Ok(line) = line {
2224+
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&line) {
2225+
messages.push(json);
2226+
}
2227+
}
2228+
}
2229+
2230+
Ok(messages)
2231+
} else {
2232+
Err(format!("Session file not found: {}", session_id))
2233+
}
2234+
}

src-tauri/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use commands::agents::{
1414
get_live_session_output, get_session_output, get_session_status, import_agent,
1515
import_agent_from_file, import_agent_from_github, init_database, kill_agent_session,
1616
list_agent_runs, list_agent_runs_with_metrics, list_agents, list_claude_installations,
17-
list_running_sessions, set_claude_binary_path, stream_session_output, update_agent, AgentDb,
17+
list_running_sessions, load_agent_session_history, set_claude_binary_path, stream_session_output, update_agent, AgentDb,
1818
};
1919
use commands::claude::{
2020
cancel_claude_execution, check_auto_checkpoint, check_claude_version, cleanup_old_checkpoints,
@@ -136,6 +136,7 @@ fn main() {
136136
get_session_output,
137137
get_live_session_output,
138138
stream_session_output,
139+
load_agent_session_history,
139140
get_claude_binary_path,
140141
set_claude_binary_path,
141142
list_claude_installations,

src/components/AgentRunOutputViewer.tsx

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,23 +116,81 @@ export function AgentRunOutputViewer({
116116
const loadOutput = async (skipCache = false) => {
117117
if (!run.id) return;
118118

119+
console.log('[AgentRunOutputViewer] Loading output for run:', {
120+
runId: run.id,
121+
status: run.status,
122+
sessionId: run.session_id,
123+
skipCache
124+
});
125+
119126
try {
120127
// Check cache first if not skipping cache
121128
if (!skipCache) {
122129
const cached = getCachedOutput(run.id);
123130
if (cached) {
131+
console.log('[AgentRunOutputViewer] Found cached output');
124132
const cachedJsonlLines = cached.output.split('\n').filter(line => line.trim());
125133
setRawJsonlOutput(cachedJsonlLines);
126134
setMessages(cached.messages);
127135
// If cache is recent (less than 5 seconds old) and session isn't running, use cache only
128136
if (Date.now() - cached.lastUpdated < 5000 && run.status !== 'running') {
137+
console.log('[AgentRunOutputViewer] Using recent cache, skipping refresh');
129138
return;
130139
}
131140
}
132141
}
133142

134143
setLoading(true);
144+
145+
// If we have a session_id, try to load from JSONL file first
146+
if (run.session_id && run.session_id !== '') {
147+
console.log('[AgentRunOutputViewer] Attempting to load from JSONL with session_id:', run.session_id);
148+
try {
149+
const history = await api.loadAgentSessionHistory(run.session_id);
150+
console.log('[AgentRunOutputViewer] Successfully loaded JSONL history:', history.length, 'messages');
151+
152+
// Convert history to messages format
153+
const loadedMessages: ClaudeStreamMessage[] = history.map(entry => ({
154+
...entry,
155+
type: entry.type || "assistant"
156+
}));
157+
158+
setMessages(loadedMessages);
159+
setRawJsonlOutput(history.map(h => JSON.stringify(h)));
160+
161+
// Update cache
162+
setCachedOutput(run.id, {
163+
output: history.map(h => JSON.stringify(h)).join('\n'),
164+
messages: loadedMessages,
165+
lastUpdated: Date.now(),
166+
status: run.status
167+
});
168+
169+
// Set up live event listeners for running sessions
170+
if (run.status === 'running') {
171+
console.log('[AgentRunOutputViewer] Setting up live listeners for running session');
172+
setupLiveEventListeners();
173+
174+
try {
175+
await api.streamSessionOutput(run.id);
176+
} catch (streamError) {
177+
console.warn('[AgentRunOutputViewer] Failed to start streaming, will poll instead:', streamError);
178+
}
179+
}
180+
181+
return;
182+
} catch (err) {
183+
console.warn('[AgentRunOutputViewer] Failed to load from JSONL:', err);
184+
console.warn('[AgentRunOutputViewer] Falling back to regular output method');
185+
}
186+
} else {
187+
console.log('[AgentRunOutputViewer] No session_id available, using fallback method');
188+
}
189+
190+
// Fallback to the original method if JSONL loading fails or no session_id
191+
console.log('[AgentRunOutputViewer] Using getSessionOutput fallback');
135192
const rawOutput = await api.getSessionOutput(run.id);
193+
console.log('[AgentRunOutputViewer] Received raw output:', rawOutput.length, 'characters');
136194

137195
// Parse JSONL output into messages
138196
const jsonlLines = rawOutput.split('\n').filter(line => line.trim());
@@ -144,9 +202,10 @@ export function AgentRunOutputViewer({
144202
const message = JSON.parse(line) as ClaudeStreamMessage;
145203
parsedMessages.push(message);
146204
} catch (err) {
147-
console.error("Failed to parse message:", err, line);
205+
console.error("[AgentRunOutputViewer] Failed to parse message:", err, line);
148206
}
149207
}
208+
console.log('[AgentRunOutputViewer] Parsed', parsedMessages.length, 'messages from output');
150209
setMessages(parsedMessages);
151210

152211
// Update cache
@@ -159,12 +218,13 @@ export function AgentRunOutputViewer({
159218

160219
// Set up live event listeners for running sessions
161220
if (run.status === 'running') {
221+
console.log('[AgentRunOutputViewer] Setting up live listeners for running session (fallback)');
162222
setupLiveEventListeners();
163223

164224
try {
165225
await api.streamSessionOutput(run.id);
166226
} catch (streamError) {
167-
console.warn('Failed to start streaming, will poll instead:', streamError);
227+
console.warn('[AgentRunOutputViewer] Failed to start streaming (fallback), will poll instead:', streamError);
168228
}
169229
}
170230
} catch (error) {

0 commit comments

Comments
 (0)