Skip to content

Commit 0bee3a8

Browse files
Merge pull request #239 from ScriptedAlchemy/codex/lcm-cross-worktree-branch-capture
Preserve transcript location metadata across providers
2 parents 2706da2 + bc088ca commit 0bee3a8

26 files changed

Lines changed: 1774 additions & 386 deletions

src/diagnostics/lsp/client.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,8 @@ impl StdioLspClient {
144144
}) {
145145
let _ = child.start_kill();
146146
let _ = child.wait().await;
147+
let _ = stderr_task.await;
147148
let stderr = captured_stderr(&stderr_capture).await;
148-
stderr_task.abort();
149149
return Err(enrich_start_error(command, err, &stderr));
150150
}
151151
write_message_with_timeout(

src/mcp/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
77
pub(crate) mod degraded;
88
pub(crate) mod hook_events;
9+
pub(crate) mod project_route;
910
/// MCP server implementation.
1011
pub mod response_handles;
1112
pub mod server;

src/mcp/project_route.rs

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
use std::collections::HashMap;
2+
3+
use serde_json::{json, Value};
4+
5+
use super::hook_events;
6+
use super::tools::tool_dispatches_registered_project_reader;
7+
8+
#[derive(Default)]
9+
pub(crate) struct HookProjectRouteCache {
10+
project_path: Option<String>,
11+
paths_by_session: HashMap<String, String>,
12+
paths_by_thread: HashMap<String, String>,
13+
}
14+
15+
impl HookProjectRouteCache {
16+
pub(crate) fn route_cwd(event: &hook_events::HookEvent) -> Option<&std::path::Path> {
17+
event
18+
.route
19+
.as_ref()
20+
.and_then(|route| route.cwd.as_deref())
21+
.or(event.cwd.as_deref())
22+
}
23+
24+
pub(crate) fn observe_hook_event(
25+
&mut self,
26+
event: &hook_events::HookEvent,
27+
project_path: Option<String>,
28+
) {
29+
self.project_path.clone_from(&project_path);
30+
let Some(project_path) = project_path else {
31+
return;
32+
};
33+
if let Some(route) = event.route.as_ref() {
34+
if let Some(session_id) = route.session_id.as_deref().filter(|id| !id.is_empty()) {
35+
self.paths_by_session
36+
.insert(session_id.to_string(), project_path.clone());
37+
}
38+
if let Some(thread_id) = route.thread_id.as_deref().filter(|id| !id.is_empty()) {
39+
self.paths_by_thread
40+
.insert(thread_id.to_string(), project_path);
41+
}
42+
}
43+
}
44+
45+
pub(crate) fn apply_to_tool_arguments(&self, tool_name: &str, mut arguments: Value) -> Value {
46+
if !tool_dispatches_registered_project_reader(tool_name)
47+
|| arguments_have_project_selector(&arguments)
48+
{
49+
return arguments;
50+
}
51+
let Some(project_path) = self.project_path_for_arguments(&arguments) else {
52+
return arguments;
53+
};
54+
if let Some(map) = arguments.as_object_mut() {
55+
map.insert(
56+
"project_selector".to_string(),
57+
json!({ "path": project_path }),
58+
);
59+
}
60+
arguments
61+
}
62+
63+
fn project_path_for_arguments(&self, arguments: &Value) -> Option<&str> {
64+
if let Some(thread_id) = mcp_route_thread_id(arguments) {
65+
if let Some(project_path) = self.paths_by_thread.get(&thread_id) {
66+
return Some(project_path.as_str());
67+
}
68+
}
69+
if let Some(session_id) = mcp_analytics_session_id(arguments) {
70+
if let Some(project_path) = self.paths_by_session.get(&session_id) {
71+
return Some(project_path.as_str());
72+
}
73+
}
74+
self.project_path.as_deref()
75+
}
76+
}
77+
78+
pub(crate) fn mcp_analytics_session_id(arguments: &Value) -> Option<String> {
79+
route_identity_from_arguments(arguments, &["session_id", "sessionId"])
80+
}
81+
82+
fn mcp_route_thread_id(arguments: &Value) -> Option<String> {
83+
route_identity_from_arguments(arguments, &["thread_id", "threadId"])
84+
}
85+
86+
fn route_identity_from_arguments(arguments: &Value, keys: &[&str]) -> Option<String> {
87+
fn string_field(value: &Value, key: &str) -> Option<String> {
88+
value
89+
.get(key)
90+
.and_then(Value::as_str)
91+
.map(str::trim)
92+
.filter(|s| !s.is_empty())
93+
.map(ToOwned::to_owned)
94+
}
95+
96+
[Some(arguments), arguments.get("_meta")]
97+
.into_iter()
98+
.flatten()
99+
.find_map(|value| keys.iter().find_map(|key| string_field(value, key)))
100+
}
101+
102+
fn arguments_have_project_selector(arguments: &Value) -> bool {
103+
arguments.get("project_selector").is_some()
104+
|| arguments.get("project_id").is_some()
105+
|| arguments.get("project_path").is_some()
106+
|| arguments.get("project_root").is_some()
107+
}
108+
109+
#[cfg(test)]
110+
mod tests {
111+
use serde_json::json;
112+
113+
use super::HookProjectRouteCache;
114+
115+
#[test]
116+
fn route_prefers_thread_then_session_then_last_hook_path() {
117+
let mut cache = HookProjectRouteCache {
118+
project_path: Some("/repo/default".to_string()),
119+
..HookProjectRouteCache::default()
120+
};
121+
cache
122+
.paths_by_session
123+
.insert("session-a".to_string(), "/repo/session-a".to_string());
124+
cache
125+
.paths_by_thread
126+
.insert("thread-a".to_string(), "/repo/thread-a".to_string());
127+
128+
assert_eq!(
129+
cache.project_path_for_arguments(
130+
&json!({"session_id": "session-a", "thread_id": "thread-a"})
131+
),
132+
Some("/repo/thread-a")
133+
);
134+
assert_eq!(
135+
cache.project_path_for_arguments(&json!({"session_id": "session-a"})),
136+
Some("/repo/session-a")
137+
);
138+
assert_eq!(
139+
cache.project_path_for_arguments(&json!({"session_id": "unknown"})),
140+
Some("/repo/default")
141+
);
142+
}
143+
144+
#[test]
145+
fn route_reads_thread_and_session_ids_from_meta() {
146+
let mut cache = HookProjectRouteCache::default();
147+
cache
148+
.paths_by_session
149+
.insert("session-meta".to_string(), "/repo/session-meta".to_string());
150+
cache
151+
.paths_by_thread
152+
.insert("thread-meta".to_string(), "/repo/thread-meta".to_string());
153+
154+
assert_eq!(
155+
cache.project_path_for_arguments(
156+
&json!({"_meta": {"sessionId": "session-meta", "threadId": "thread-meta"}})
157+
),
158+
Some("/repo/thread-meta")
159+
);
160+
}
161+
162+
#[test]
163+
fn route_injects_selector_without_overriding_explicit_selector() {
164+
let mut cache = HookProjectRouteCache::default();
165+
cache
166+
.paths_by_session
167+
.insert("session-a".to_string(), "/repo/session-a".to_string());
168+
169+
let routed = cache.apply_to_tool_arguments(
170+
"tracedecay_context",
171+
json!({"task": "inspect routing", "session_id": "session-a"}),
172+
);
173+
assert_eq!(routed["project_selector"]["path"], "/repo/session-a");
174+
175+
let explicit = cache.apply_to_tool_arguments(
176+
"tracedecay_context",
177+
json!({
178+
"task": "inspect routing",
179+
"session_id": "session-a",
180+
"project_selector": {"path": "/repo/explicit"},
181+
}),
182+
);
183+
assert_eq!(explicit["project_selector"]["path"], "/repo/explicit");
184+
}
185+
}

0 commit comments

Comments
 (0)