Skip to content

Commit 0abd64a

Browse files
author
Marvin Zhang
committed
feat: implement Copilot adapter and integrate with server; add configuration management
1 parent 6e31406 commit 0abd64a

File tree

11 files changed

+746
-32
lines changed

11 files changed

+746
-32
lines changed

rust/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,6 @@ regex = "1.10"
3434
uuid = { version = "1.7", features = ["v4", "serde"] }
3535
reqwest = { version = "0.11", features = ["json"] }
3636
config = "0.14"
37+
axum = { version = "0.7", features = ["ws"] }
38+
tower-http = { version = "0.5", features = ["cors", "fs"] }
39+
tower = "0.4"

rust/devlog-adapters/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,6 @@ regex.workspace = true
1717
log.workspace = true
1818
tokio.workspace = true
1919
uuid.workspace = true
20+
21+
[dev-dependencies]
22+
tempfile = "3.10"
Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
use crate::AgentAdapter;
2+
use async_trait::async_trait;
3+
use devlog_core::{AgentEvent, EventMetrics, EVENT_TYPE_LLM_REQUEST, EVENT_TYPE_LLM_RESPONSE, EVENT_TYPE_TOOL_USE, EVENT_TYPE_FILE_READ, EVENT_TYPE_FILE_MODIFY};
4+
use std::path::Path;
5+
use anyhow::{Result, Context, anyhow};
6+
use serde::{Deserialize, Serialize};
7+
use serde_json::Value;
8+
use chrono::{DateTime, Utc, TimeZone};
9+
use std::collections::HashMap;
10+
use uuid::Uuid;
11+
use tokio::fs;
12+
13+
pub struct CopilotAdapter {
14+
name: String,
15+
project_id: String,
16+
}
17+
18+
impl CopilotAdapter {
19+
pub fn new(project_id: String) -> Self {
20+
Self {
21+
name: "github-copilot".to_string(),
22+
project_id,
23+
}
24+
}
25+
26+
fn extract_session_id(&self, file_path: &Path) -> String {
27+
file_path
28+
.file_stem()
29+
.and_then(|s| s.to_str())
30+
.unwrap_or_default()
31+
.to_string()
32+
}
33+
34+
fn extract_workspace_id(&self, file_path: &Path) -> String {
35+
let components: Vec<_> = file_path.components().collect();
36+
for i in 0..components.len() {
37+
if let Some(name) = components[i].as_os_str().to_str() {
38+
if name == "workspaceStorage" && i + 1 < components.len() {
39+
return components[i + 1].as_os_str().to_str().unwrap_or_default().to_string();
40+
}
41+
}
42+
}
43+
"".to_string()
44+
}
45+
46+
fn parse_timestamp(&self, ts: &Value) -> DateTime<Utc> {
47+
match ts {
48+
Value::String(s) => {
49+
if let Ok(t) = DateTime::parse_from_rfc3339(s) {
50+
return t.with_timezone(&Utc);
51+
}
52+
Utc::now()
53+
}
54+
Value::Number(n) => {
55+
if let Some(ms) = n.as_i64() {
56+
return Utc.timestamp_millis_opt(ms).unwrap();
57+
}
58+
Utc::now()
59+
}
60+
_ => Utc::now(),
61+
}
62+
}
63+
64+
fn extract_value_as_string(&self, val: &Value) -> String {
65+
match val {
66+
Value::String(s) => s.clone(),
67+
Value::Array(arr) => {
68+
arr.iter()
69+
.filter_map(|v| v.as_str())
70+
.collect::<Vec<_>>()
71+
.join("\n")
72+
}
73+
_ => "".to_string(),
74+
}
75+
}
76+
77+
fn extract_file_path(&self, uri: &Value) -> String {
78+
if let Some(obj) = uri.as_object() {
79+
if let Some(path) = obj.get("path").and_then(|v| v.as_str()) {
80+
return path.to_string();
81+
}
82+
if let Some(fs_path) = obj.get("fsPath").and_then(|v| v.as_str()) {
83+
return fs_path.to_string();
84+
}
85+
}
86+
"".to_string()
87+
}
88+
89+
fn estimate_tokens(&self, text: &str) -> i32 {
90+
(text.split_whitespace().count() as f64 * 1.3) as i32
91+
}
92+
}
93+
94+
#[async_trait]
95+
impl AgentAdapter for CopilotAdapter {
96+
fn name(&self) -> &str {
97+
&self.name
98+
}
99+
100+
fn parse_log_line(&self, _line: &str) -> Result<Option<AgentEvent>> {
101+
Err(anyhow!("line-based parsing not supported for Copilot chat sessions"))
102+
}
103+
104+
async fn parse_log_file(&self, file_path: &Path) -> Result<Vec<AgentEvent>> {
105+
let data = fs::read_to_string(file_path).await.context("failed to read chat session file")?;
106+
let session: CopilotChatSession = serde_json::from_str(&data).context("failed to parse chat session JSON")?;
107+
108+
let session_id = self.extract_session_id(file_path);
109+
let workspace_id = self.extract_workspace_id(file_path);
110+
111+
let mut events = Vec::new();
112+
113+
for request in session.requests {
114+
if request.is_canceled {
115+
continue;
116+
}
117+
118+
let timestamp = self.parse_timestamp(&request.timestamp);
119+
120+
// 1. LLM Request Event
121+
let prompt_text = request.message.text.clone();
122+
let mut req_event = AgentEvent {
123+
id: Uuid::new_v4().to_string(),
124+
timestamp,
125+
event_type: EVENT_TYPE_LLM_REQUEST.to_string(),
126+
agent_id: self.name.clone(),
127+
agent_version: "1.0.0".to_string(),
128+
session_id: session_id.clone(),
129+
project_id: 0,
130+
machine_id: None,
131+
workspace_id: None,
132+
legacy_project_id: Some(self.project_id.clone()),
133+
context: HashMap::from([
134+
("username".to_string(), Value::String(session.requester_username.clone())),
135+
("workspaceId".to_string(), Value::String(workspace_id.clone())),
136+
]),
137+
data: HashMap::from([
138+
("requestId".to_string(), Value::String(request.request_id.clone())),
139+
("modelId".to_string(), Value::String(request.model_id.clone())),
140+
("prompt".to_string(), Value::String(prompt_text.clone())),
141+
("promptLength".to_string(), Value::Number(prompt_text.len().into())),
142+
]),
143+
metrics: Some(EventMetrics {
144+
prompt_tokens: Some(self.estimate_tokens(&prompt_text)),
145+
..Default::default()
146+
}),
147+
};
148+
events.push(req_event);
149+
150+
// 2. File References from variables
151+
for var in request.variable_data.variables {
152+
let file_path = self.extract_file_path(&Value::Object(var.value.clone().into_iter().collect()));
153+
if !file_path.is_empty() {
154+
events.push(AgentEvent {
155+
id: Uuid::new_v4().to_string(),
156+
timestamp,
157+
event_type: EVENT_TYPE_FILE_READ.to_string(),
158+
agent_id: self.name.clone(),
159+
agent_version: "1.0.0".to_string(),
160+
session_id: session_id.clone(),
161+
project_id: 0,
162+
machine_id: None,
163+
workspace_id: None,
164+
legacy_project_id: Some(self.project_id.clone()),
165+
context: HashMap::new(),
166+
data: HashMap::from([
167+
("requestId".to_string(), Value::String(request.request_id.clone())),
168+
("filePath".to_string(), Value::String(file_path)),
169+
("variableName".to_string(), Value::String(var.name)),
170+
]),
171+
metrics: None,
172+
});
173+
}
174+
}
175+
176+
// 3. Tool Invocations and Response Text
177+
let mut response_text_parts = Vec::new();
178+
for item in request.response {
179+
match item.kind.as_deref() {
180+
None => {
181+
let text = self.extract_value_as_string(&item.value);
182+
if !text.is_empty() {
183+
response_text_parts.push(text);
184+
}
185+
}
186+
Some("toolInvocationSerialized") => {
187+
events.push(AgentEvent {
188+
id: Uuid::new_v4().to_string(),
189+
timestamp: timestamp + chrono::Duration::milliseconds(100),
190+
event_type: EVENT_TYPE_TOOL_USE.to_string(),
191+
agent_id: self.name.clone(),
192+
agent_version: "1.0.0".to_string(),
193+
session_id: session_id.clone(),
194+
project_id: 0,
195+
machine_id: None,
196+
workspace_id: None,
197+
legacy_project_id: Some(self.project_id.clone()),
198+
context: HashMap::new(),
199+
data: HashMap::from([
200+
("requestId".to_string(), Value::String(request.request_id.clone())),
201+
("toolId".to_string(), Value::String(item.tool_id.unwrap_or_default())),
202+
("toolName".to_string(), Value::String(item.tool_name.unwrap_or_default())),
203+
]),
204+
metrics: None,
205+
});
206+
}
207+
Some("textEditGroup") => {
208+
events.push(AgentEvent {
209+
id: Uuid::new_v4().to_string(),
210+
timestamp: timestamp + chrono::Duration::milliseconds(200),
211+
event_type: EVENT_TYPE_FILE_MODIFY.to_string(),
212+
agent_id: self.name.clone(),
213+
agent_version: "1.0.0".to_string(),
214+
session_id: session_id.clone(),
215+
project_id: 0,
216+
machine_id: None,
217+
workspace_id: None,
218+
legacy_project_id: Some(self.project_id.clone()),
219+
context: HashMap::new(),
220+
data: HashMap::from([
221+
("requestId".to_string(), Value::String(request.request_id.clone())),
222+
]),
223+
metrics: None,
224+
});
225+
}
226+
_ => {}
227+
}
228+
}
229+
230+
// 4. LLM Response Event
231+
let response_text = response_text_parts.join("");
232+
events.push(AgentEvent {
233+
id: Uuid::new_v4().to_string(),
234+
timestamp: timestamp + chrono::Duration::seconds(1),
235+
event_type: EVENT_TYPE_LLM_RESPONSE.to_string(),
236+
agent_id: self.name.clone(),
237+
agent_version: "1.0.0".to_string(),
238+
session_id: session_id.clone(),
239+
project_id: 0,
240+
machine_id: None,
241+
workspace_id: None,
242+
legacy_project_id: Some(self.project_id.clone()),
243+
context: HashMap::new(),
244+
data: HashMap::from([
245+
("requestId".to_string(), Value::String(request.request_id.clone())),
246+
("response".to_string(), Value::String(response_text.clone())),
247+
("responseLength".to_string(), Value::Number(response_text.len().into())),
248+
]),
249+
metrics: Some(EventMetrics {
250+
response_tokens: Some(self.estimate_tokens(&response_text)),
251+
..Default::default()
252+
}),
253+
});
254+
}
255+
256+
Ok(events)
257+
}
258+
259+
fn supports_format(&self, sample: &str) -> bool {
260+
let session: Result<CopilotChatSession, _> = serde_json::from_str(sample);
261+
match session {
262+
Ok(s) => s.version > 0 && !s.requests.is_empty(),
263+
Err(_) => false,
264+
}
265+
}
266+
}
267+
268+
#[derive(Debug, Deserialize)]
269+
#[serde(rename_all = "camelCase")]
270+
struct CopilotChatSession {
271+
version: i32,
272+
requester_username: String,
273+
requests: Vec<CopilotRequest>,
274+
}
275+
276+
#[derive(Debug, Deserialize)]
277+
#[serde(rename_all = "camelCase")]
278+
struct CopilotRequest {
279+
request_id: String,
280+
timestamp: Value,
281+
model_id: String,
282+
message: CopilotMessage,
283+
response: Vec<CopilotResponseItem>,
284+
variable_data: CopilotVariableData,
285+
#[serde(default)]
286+
is_canceled: bool,
287+
}
288+
289+
#[derive(Debug, Deserialize)]
290+
struct CopilotMessage {
291+
text: String,
292+
}
293+
294+
#[derive(Debug, Deserialize)]
295+
#[serde(rename_all = "camelCase")]
296+
struct CopilotResponseItem {
297+
kind: Option<String>,
298+
#[serde(default)]
299+
value: Value,
300+
tool_id: Option<String>,
301+
tool_name: Option<String>,
302+
}
303+
304+
#[derive(Debug, Deserialize)]
305+
struct CopilotVariableData {
306+
variables: Vec<CopilotVariable>,
307+
}
308+
309+
#[derive(Debug, Deserialize)]
310+
struct CopilotVariable {
311+
name: String,
312+
value: HashMap<String, Value>,
313+
}
314+
315+
#[cfg(test)]
316+
mod tests {
317+
use super::*;
318+
use std::io::Write;
319+
use tempfile::NamedTempFile;
320+
321+
#[tokio::test]
322+
async fn test_copilot_adapter_parse_file() {
323+
let mut file = NamedTempFile::new().unwrap();
324+
let json_content = r#"{
325+
"version": 1,
326+
"requesterUsername": "testuser",
327+
"requests": [
328+
{
329+
"requestId": "req-1",
330+
"timestamp": 1700000000000,
331+
"modelId": "gpt-4",
332+
"message": { "text": "Hello" },
333+
"response": [
334+
{ "value": "Hi there" }
335+
],
336+
"variableData": { "variables": [] },
337+
"isCanceled": false
338+
}
339+
]
340+
}"#;
341+
file.write_all(json_content.as_bytes()).unwrap();
342+
343+
let adapter = CopilotAdapter::new("test-project".to_string());
344+
let events = adapter.parse_log_file(file.path()).await.unwrap();
345+
346+
assert_eq!(events.len(), 2); // Request and Response
347+
assert_eq!(events[0].event_type, EVENT_TYPE_LLM_REQUEST);
348+
assert_eq!(events[1].event_type, EVENT_TYPE_LLM_RESPONSE);
349+
assert_eq!(events[0].data["prompt"], "Hello");
350+
assert_eq!(events[1].data["response"], "Hi there");
351+
}
352+
}

rust/devlog-adapters/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub trait AgentAdapter: Send + Sync {
1212
}
1313

1414
pub mod claude;
15+
pub mod copilot;
1516
pub mod registry;
1617

1718
pub use registry::Registry;

rust/devlog-cli/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,7 @@ env_logger.workspace = true
2121
config.workspace = true
2222
serde.workspace = true
2323
chrono.workspace = true
24+
axum.workspace = true
25+
tower-http.workspace = true
26+
tower.workspace = true
27+
clap_complete = "4.5"

0 commit comments

Comments
 (0)