Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 87 additions & 23 deletions agent-sessions/src/providers/claude/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ impl Claude {
}

if cwd.is_none() {
if let Some(value) = raw.cwd {
if let Some(value) = raw.cwd.as_deref() {
cwd = Some(box_str(value));
if want_title {
if let Some(found) = extract_user_message_title(&raw) {
Expand Down Expand Up @@ -311,7 +311,7 @@ where
.and_then(|message| opt_box_str(message.id)),
role: map_role(raw.kind.unwrap_or("unknown")),
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
model: raw
.message
.as_ref()
Expand Down Expand Up @@ -356,7 +356,7 @@ where
.and_then(|value| opt_box_str(value.attachment_type)),
name: attachment
.as_ref()
.and_then(|value| opt_box_str(value.name)),
.and_then(|value| opt_cow_to_box(value.name.clone())),
species: attachment
.as_ref()
.and_then(|value| opt_box_str(value.species)),
Expand Down Expand Up @@ -390,7 +390,7 @@ where
if selection.includes_state() {
entries.push(Entry::LastPrompt(LastPromptEntry {
session_id: opt_box_str(raw.session_id),
last_prompt: opt_box_str(raw.last_prompt),
last_prompt: raw.last_prompt.map(cow_to_box),
}));
}
}
Expand All @@ -405,19 +405,20 @@ where
Some("hook_progress") => {
entries.push(Entry::Progress(ProgressEntry::HookProgress {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
hook_event: progress.and_then(|value| opt_box_str(value.hook_event)),
hook_name: progress.and_then(|value| opt_box_str(value.hook_name)),
command: progress.and_then(|value| opt_box_str(value.command)),
command: progress
.and_then(|value| opt_cow_to_box(value.command.clone())),
}))
}
Some("bash_progress") => {
entries.push(Entry::Progress(ProgressEntry::BashProgress {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand All @@ -432,7 +433,7 @@ where
Some("agent_progress") => {
entries.push(Entry::Progress(ProgressEntry::AgentProgress {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand All @@ -444,7 +445,7 @@ where
Some("query_update") => {
entries.push(Entry::Progress(ProgressEntry::QueryUpdate {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand All @@ -454,7 +455,7 @@ where
Some("search_results_received") => {
entries.push(Entry::Progress(ProgressEntry::SearchResultsReceived {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand All @@ -465,7 +466,7 @@ where
Some("mcp_progress") => {
entries.push(Entry::Progress(ProgressEntry::McpProgress {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand All @@ -477,19 +478,19 @@ where
Some("waiting_for_task") => {
entries.push(Entry::Progress(ProgressEntry::WaitingForTask {
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
task_description: progress
.and_then(|value| opt_box_str(value.task_description)),
.and_then(|value| opt_cow_to_box(value.task_description.clone())),
task_type: progress.and_then(|value| opt_box_str(value.task_type)),
}))
}
_ => entries.push(Entry::Progress(ProgressEntry::Other {
kind: progress_kind.map(box_str),
session_id: opt_box_str(raw.session_id),
cwd: opt_box_str(raw.cwd),
cwd: opt_cow_to_box(raw.cwd.clone()),
timestamp: opt_timestamp_box_str(raw.timestamp.as_ref()),
parent_tool_use_id: opt_box_str(raw.parent_tool_use_id),
tool_use_id: opt_box_str(raw.tool_use_id),
Expand Down Expand Up @@ -526,7 +527,7 @@ where
entries.push(Entry::InputSnapshot(InputSnapshotEntry {
display: raw.display.map(cow_to_box),
pasted_contents_json: opt_raw_json_box(raw.pasted_contents),
project: opt_box_str(raw.project),
project: raw.project.map(cow_to_box),
session_id: opt_box_str(raw.session_id),
timestamp_millis: opt_timestamp_millis(raw.timestamp.as_ref()),
}));
Expand Down Expand Up @@ -592,7 +593,7 @@ where
};
}
if cwd.is_none() && raw.cwd.is_some() {
cwd = opt_box_str(raw.cwd);
cwd = opt_cow_to_box(raw.cwd.clone());
if timestamp.is_none() {
timestamp = opt_timestamp_box_str(raw.timestamp.as_ref());
}
Expand Down Expand Up @@ -715,8 +716,8 @@ struct RawEntry<'a> {
kind: Option<&'a str>,
#[serde(default, alias = "sessionId")]
session_id: Option<&'a str>,
#[serde(default)]
cwd: Option<&'a str>,
#[serde(default, borrow)]
cwd: Option<Cow<'a, str>>,
#[serde(default, alias = "isMeta")]
is_meta: Option<bool>,
#[serde(default, deserialize_with = "deserialize_opt_timestamp")]
Expand All @@ -738,7 +739,7 @@ struct RawEntry<'a> {
#[serde(default)]
snapshot: Option<SnapshotPayload<'a>>,
#[serde(default, alias = "lastPrompt")]
last_prompt: Option<&'a str>,
last_prompt: Option<Cow<'a, str>>,
#[serde(default)]
operation: Option<&'a str>,
#[serde(default, borrow)]
Expand All @@ -748,7 +749,7 @@ struct RawEntry<'a> {
#[serde(default, alias = "pastedContents", borrow)]
pasted_contents: Option<&'a RawValue>,
#[serde(default)]
project: Option<&'a str>,
project: Option<Cow<'a, str>>,
#[serde(default)]
subtype: Option<&'a str>,
#[serde(default)]
Expand Down Expand Up @@ -981,7 +982,7 @@ struct AttachmentPayload<'a> {
#[serde(default)]
attachment_type: Option<&'a str>,
#[serde(default)]
name: Option<&'a str>,
name: Option<Cow<'a, str>>,
#[serde(default)]
species: Option<&'a str>,
}
Expand All @@ -1003,7 +1004,7 @@ struct ProgressPayload<'a> {
#[serde(default)]
hook_name: Option<&'a str>,
#[serde(default)]
command: Option<&'a str>,
command: Option<Cow<'a, str>>,
#[serde(default)]
output: Option<Cow<'a, str>>,
#[serde(default, alias = "fullOutput")]
Expand All @@ -1029,7 +1030,7 @@ struct ProgressPayload<'a> {
#[serde(default, alias = "toolName")]
tool_name: Option<&'a str>,
#[serde(default, alias = "taskDescription")]
task_description: Option<&'a str>,
task_description: Option<Cow<'a, str>>,
#[serde(default, alias = "taskType")]
task_type: Option<&'a str>,
}
Expand Down Expand Up @@ -1094,6 +1095,69 @@ mod tests {
assert_eq!(session_id.as_deref(), Some("sess-1"));
}

#[test]
fn reader_accepts_escaped_windows_cwd() {
let bytes = concat!(
r#"{"type":"user","sessionId":"sess-win","cwd":"D:\\Input\\Api\\src\\InputService","timestamp":"2026-05-01T11:09:46.305Z","message":{"id":"msg-win","model":"claude-sonnet-4","content":"hello from windows"}}"#,
"\n",
);

let (_version, body) = super::parse_claude_reader(
Cursor::new(bytes.as_bytes()),
crate::ParseSelection::full(),
)
.unwrap();

let [super::Entry::Message(message)] = body.entries.as_ref() else {
panic!("expected one message entry");
};
assert_eq!(message.session_id.as_deref(), Some("sess-win"));
assert_eq!(
message.cwd.as_deref(),
Some(r#"D:\Input\Api\src\InputService"#)
);
}

#[test]
fn reader_accepts_escaped_windows_progress_command() {
let bytes = concat!(
r#"{"type":"progress","sessionId":"sess-win","cwd":"D:\\Input","timestamp":"2026-05-01T11:09:46.305Z","data":{"type":"hook_progress","command":"cd D:\\Input\\Api\nrun tests"}}"#,
"\n",
);

let (_version, body) = super::parse_claude_reader(
Cursor::new(bytes.as_bytes()),
crate::ParseSelection::full(),
)
.unwrap();

let [super::Entry::Progress(super::ProgressEntry::HookProgress { command, .. })] =
body.entries.as_ref()
else {
panic!("expected hook progress entry");
};
assert_eq!(command.as_deref(), Some("cd D:\\Input\\Api\nrun tests"));
}

#[cfg(feature = "agent_session")]
#[test]
fn probe_session_meta_accepts_escaped_windows_cwd() {
let bytes = concat!(
r#"{"type":"user","sessionId":"sess-win","cwd":"D:\\Input\\Api\\src\\InputService","timestamp":"2026-05-01T11:09:46.305Z"}"#,
"\n",
);

let meta = super::Claude::probe_session_meta(Cursor::new(bytes.as_bytes()))
.unwrap()
.expect("expected session meta");

assert_eq!(meta.session_id.as_deref(), Some("sess-win"));
assert_eq!(
meta.cwd.as_deref(),
Some(r#"D:\Input\Api\src\InputService"#)
);
}

#[cfg(feature = "agent_session")]
#[test]
fn probe_session_meta_keeps_reading_until_cwd_seen() {
Expand Down
57 changes: 41 additions & 16 deletions agent-sessions/src/providers/codex/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,8 @@ fn parse_codex_response_item_entry<'a>(
commit
.preexisting_untracked_files
.iter()
.map(|path| box_str(path))
.cloned()
.map(cow_to_box)
.collect::<Vec<_>>()
.into_boxed_slice()
})
Expand All @@ -508,7 +509,8 @@ fn parse_codex_response_item_entry<'a>(
commit
.preexisting_untracked_dirs
.iter()
.map(|path| box_str(path))
.cloned()
.map(cow_to_box)
.collect::<Vec<_>>()
.into_boxed_slice()
})
Expand Down Expand Up @@ -755,8 +757,8 @@ fn map_event_msg_data(payload: &EventMsgPayload<'_>) -> EventMsgData {
call_id: opt_box_str(payload.call_id),
sender_thread_id: opt_box_str(payload.sender_thread_id),
new_thread_id: opt_box_str(payload.new_thread_id),
new_agent_nickname: opt_box_str(payload.new_agent_nickname),
new_agent_role: opt_box_str(payload.new_agent_role),
new_agent_nickname: opt_cow_box_str(payload.new_agent_nickname.clone()),
new_agent_role: opt_cow_box_str(payload.new_agent_role.clone()),
prompt: opt_cow_box_str(payload.prompt.clone()),
model: opt_box_str(payload.model),
reasoning_effort: opt_box_str(payload.reasoning_effort),
Expand All @@ -767,24 +769,24 @@ fn map_event_msg_data(payload: &EventMsgPayload<'_>) -> EventMsgData {
call_id: opt_box_str(payload.call_id),
sender_thread_id: opt_box_str(payload.sender_thread_id),
receiver_thread_id: opt_box_str(payload.receiver_thread_id),
receiver_agent_nickname: opt_box_str(payload.receiver_agent_nickname),
receiver_agent_role: opt_box_str(payload.receiver_agent_role),
receiver_agent_nickname: opt_cow_box_str(payload.receiver_agent_nickname.clone()),
receiver_agent_role: opt_cow_box_str(payload.receiver_agent_role.clone()),
status_json: opt_raw_json_box(payload.status),
}),
"collab_agent_interaction_end" => {
EventMsgData::CollabAgentInteractionEnd(CollabAgentInteractionEndEventMsg {
call_id: opt_box_str(payload.call_id),
sender_thread_id: opt_box_str(payload.sender_thread_id),
receiver_thread_id: opt_box_str(payload.receiver_thread_id),
receiver_agent_nickname: opt_box_str(payload.receiver_agent_nickname),
receiver_agent_role: opt_box_str(payload.receiver_agent_role),
receiver_agent_nickname: opt_cow_box_str(payload.receiver_agent_nickname.clone()),
receiver_agent_role: opt_cow_box_str(payload.receiver_agent_role.clone()),
prompt: opt_cow_box_str(payload.prompt.clone()),
status_json: opt_raw_json_box(payload.status),
})
}
"error" => EventMsgData::Error(ErrorEventMsg {
message: opt_cow_box_str(payload.message.clone()),
codex_error_info: opt_box_str(payload.codex_error_info),
codex_error_info: opt_cow_box_str(payload.codex_error_info.clone()),
}),
"entered_review_mode" => EventMsgData::EnteredReviewMode(EnteredReviewModeEventMsg {
target_json: opt_raw_json_box(payload.target),
Expand Down Expand Up @@ -1065,9 +1067,9 @@ struct EventMsgPayload<'a> {
#[serde(default)]
new_thread_id: Option<&'a str>,
#[serde(default)]
new_agent_nickname: Option<&'a str>,
new_agent_nickname: Option<Cow<'a, str>>,
#[serde(default)]
new_agent_role: Option<&'a str>,
new_agent_role: Option<Cow<'a, str>>,
#[serde(default)]
prompt: Option<Cow<'a, str>>,
#[serde(default)]
Expand All @@ -1077,11 +1079,11 @@ struct EventMsgPayload<'a> {
#[serde(default)]
receiver_thread_id: Option<&'a str>,
#[serde(default)]
receiver_agent_nickname: Option<&'a str>,
receiver_agent_nickname: Option<Cow<'a, str>>,
#[serde(default)]
receiver_agent_role: Option<&'a str>,
receiver_agent_role: Option<Cow<'a, str>>,
#[serde(default)]
codex_error_info: Option<&'a str>,
codex_error_info: Option<Cow<'a, str>>,
#[serde(default, borrow)]
target: Option<&'a RawValue>,
#[serde(default)]
Expand Down Expand Up @@ -1109,9 +1111,9 @@ struct GhostCommit<'a> {
#[serde(default)]
parent: Option<&'a str>,
#[serde(default)]
preexisting_untracked_files: Vec<&'a str>,
preexisting_untracked_files: Vec<Cow<'a, str>>,
#[serde(default)]
preexisting_untracked_dirs: Vec<&'a str>,
preexisting_untracked_dirs: Vec<Cow<'a, str>>,
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -1231,6 +1233,29 @@ mod tests {
assert_eq!(meta.cwd.as_deref(), Some(r"C:\Users\alice\project"));
}

#[test]
fn reader_accepts_escaped_windows_paths_in_ghost_snapshot() {
let bytes = concat!(
r#"{"timestamp":"2026-04-16T00:00:01.000Z","type":"response_item","payload":{"type":"ghost_snapshot","ghost_commit":{"id":"abc123","preexisting_untracked_files":["C:\\Users\\alice\\project\\file.txt"],"preexisting_untracked_dirs":["D:\\work\\scratch"]}}}"#,
"\n",
);

let (_version, body) =
super::parse_codex_reader(Cursor::new(bytes), crate::ParseSelection::full()).unwrap();

let [super::Entry::GhostSnapshot(snapshot)] = body.entries.as_ref() else {
panic!("expected ghost snapshot entry");
};
assert_eq!(
snapshot.preexisting_untracked_files.as_ref(),
&[SmolStr::from(r"C:\Users\alice\project\file.txt")]
);
assert_eq!(
snapshot.preexisting_untracked_dirs.as_ref(),
&[SmolStr::from(r"D:\work\scratch")]
);
}

#[cfg(feature = "agent_session")]
#[test]
fn probe_session_meta_with_title_uses_post_session_user_text() {
Expand Down
Loading