mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-11 12:03:31 +08:00
feat: add ecc2 graph observations
This commit is contained in:
195
ecc2/src/main.rs
195
ecc2/src/main.rs
@@ -457,6 +457,39 @@ enum GraphCommands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
json: bool,
|
json: bool,
|
||||||
},
|
},
|
||||||
|
/// Record an observation against a context graph entity
|
||||||
|
AddObservation {
|
||||||
|
/// Optional source session ID or alias for provenance
|
||||||
|
#[arg(long)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
/// Entity ID
|
||||||
|
#[arg(long)]
|
||||||
|
entity_id: i64,
|
||||||
|
/// Observation type such as completion_summary, incident_note, or reminder
|
||||||
|
#[arg(long = "type")]
|
||||||
|
observation_type: String,
|
||||||
|
/// Observation summary
|
||||||
|
#[arg(long)]
|
||||||
|
summary: String,
|
||||||
|
/// Details in key=value form
|
||||||
|
#[arg(long = "detail")]
|
||||||
|
details: Vec<String>,
|
||||||
|
/// Emit machine-readable JSON instead of the human summary
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
|
/// List observations in the shared context graph
|
||||||
|
Observations {
|
||||||
|
/// Filter to observations for a specific entity ID
|
||||||
|
#[arg(long)]
|
||||||
|
entity_id: Option<i64>,
|
||||||
|
/// Maximum observations to return
|
||||||
|
#[arg(long, default_value_t = 20)]
|
||||||
|
limit: usize,
|
||||||
|
/// Emit machine-readable JSON instead of the human summary
|
||||||
|
#[arg(long)]
|
||||||
|
json: bool,
|
||||||
|
},
|
||||||
/// Recall relevant context graph entities for a query
|
/// Recall relevant context graph entities for a query
|
||||||
Recall {
|
Recall {
|
||||||
/// Filter by source session ID or alias
|
/// Filter by source session ID or alias
|
||||||
@@ -1243,6 +1276,44 @@ async fn main() -> Result<()> {
|
|||||||
println!("{}", format_graph_relations_human(&relations));
|
println!("{}", format_graph_relations_human(&relations));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
GraphCommands::AddObservation {
|
||||||
|
session_id,
|
||||||
|
entity_id,
|
||||||
|
observation_type,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let resolved_session_id = session_id
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| resolve_session_id(&db, value))
|
||||||
|
.transpose()?;
|
||||||
|
let details = parse_key_value_pairs(&details, "graph observation details")?;
|
||||||
|
let observation = db.add_context_observation(
|
||||||
|
resolved_session_id.as_deref(),
|
||||||
|
entity_id,
|
||||||
|
&observation_type,
|
||||||
|
&summary,
|
||||||
|
&details,
|
||||||
|
)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&observation)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", format_graph_observation_human(&observation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GraphCommands::Observations {
|
||||||
|
entity_id,
|
||||||
|
limit,
|
||||||
|
json,
|
||||||
|
} => {
|
||||||
|
let observations = db.list_context_observations(entity_id, limit)?;
|
||||||
|
if json {
|
||||||
|
println!("{}", serde_json::to_string_pretty(&observations)?);
|
||||||
|
} else {
|
||||||
|
println!("{}", format_graph_observations_human(&observations));
|
||||||
|
}
|
||||||
|
}
|
||||||
GraphCommands::Recall {
|
GraphCommands::Recall {
|
||||||
session_id,
|
session_id,
|
||||||
query,
|
query,
|
||||||
@@ -2249,6 +2320,58 @@ fn format_graph_relations_human(relations: &[session::ContextGraphRelation]) ->
|
|||||||
lines.join("\n")
|
lines.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn format_graph_observation_human(observation: &session::ContextGraphObservation) -> String {
|
||||||
|
let mut lines = vec![
|
||||||
|
format!("Context graph observation #{}", observation.id),
|
||||||
|
format!(
|
||||||
|
"Entity: #{} [{}] {}",
|
||||||
|
observation.entity_id, observation.entity_type, observation.entity_name
|
||||||
|
),
|
||||||
|
format!("Type: {}", observation.observation_type),
|
||||||
|
format!("Summary: {}", observation.summary),
|
||||||
|
];
|
||||||
|
if let Some(session_id) = observation.session_id.as_deref() {
|
||||||
|
lines.push(format!("Session: {}", short_session(session_id)));
|
||||||
|
}
|
||||||
|
if observation.details.is_empty() {
|
||||||
|
lines.push("Details: none recorded".to_string());
|
||||||
|
} else {
|
||||||
|
lines.push("Details:".to_string());
|
||||||
|
for (key, value) in &observation.details {
|
||||||
|
lines.push(format!("- {key}={value}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push(format!(
|
||||||
|
"Created: {}",
|
||||||
|
observation.created_at.format("%Y-%m-%d %H:%M:%S UTC")
|
||||||
|
));
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_graph_observations_human(observations: &[session::ContextGraphObservation]) -> String {
|
||||||
|
if observations.is_empty() {
|
||||||
|
return "No context graph observations found.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines = vec![format!(
|
||||||
|
"Context graph observations: {}",
|
||||||
|
observations.len()
|
||||||
|
)];
|
||||||
|
for observation in observations {
|
||||||
|
let mut line = format!(
|
||||||
|
"- #{} [{}] {}",
|
||||||
|
observation.id, observation.observation_type, observation.entity_name
|
||||||
|
);
|
||||||
|
if let Some(session_id) = observation.session_id.as_deref() {
|
||||||
|
line.push_str(&format!(" | {}", short_session(session_id)));
|
||||||
|
}
|
||||||
|
lines.push(line);
|
||||||
|
lines.push(format!(" summary {}", observation.summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
fn format_graph_recall_human(
|
fn format_graph_recall_human(
|
||||||
entries: &[session::ContextGraphRecallEntry],
|
entries: &[session::ContextGraphRecallEntry],
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
@@ -2268,12 +2391,13 @@ fn format_graph_recall_human(
|
|||||||
)];
|
)];
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let mut line = format!(
|
let mut line = format!(
|
||||||
"- #{} [{}] {} | score {} | relations {}",
|
"- #{} [{}] {} | score {} | relations {} | observations {}",
|
||||||
entry.entity.id,
|
entry.entity.id,
|
||||||
entry.entity.entity_type,
|
entry.entity.entity_type,
|
||||||
entry.entity.name,
|
entry.entity.name,
|
||||||
entry.score,
|
entry.score,
|
||||||
entry.relation_count
|
entry.relation_count,
|
||||||
|
entry.observation_count
|
||||||
);
|
);
|
||||||
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
||||||
line.push_str(&format!(" | {}", short_session(session_id)));
|
line.push_str(&format!(" | {}", short_session(session_id)));
|
||||||
@@ -4226,6 +4350,49 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cli_parses_graph_add_observation_command() {
|
||||||
|
let cli = Cli::try_parse_from([
|
||||||
|
"ecc",
|
||||||
|
"graph",
|
||||||
|
"add-observation",
|
||||||
|
"--session-id",
|
||||||
|
"latest",
|
||||||
|
"--entity-id",
|
||||||
|
"7",
|
||||||
|
"--type",
|
||||||
|
"completion_summary",
|
||||||
|
"--summary",
|
||||||
|
"Finished auth callback recovery",
|
||||||
|
"--detail",
|
||||||
|
"tests_run=2",
|
||||||
|
"--json",
|
||||||
|
])
|
||||||
|
.expect("graph add-observation should parse");
|
||||||
|
|
||||||
|
match cli.command {
|
||||||
|
Some(Commands::Graph {
|
||||||
|
command:
|
||||||
|
GraphCommands::AddObservation {
|
||||||
|
session_id,
|
||||||
|
entity_id,
|
||||||
|
observation_type,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
json,
|
||||||
|
},
|
||||||
|
}) => {
|
||||||
|
assert_eq!(session_id.as_deref(), Some("latest"));
|
||||||
|
assert_eq!(entity_id, 7);
|
||||||
|
assert_eq!(observation_type, "completion_summary");
|
||||||
|
assert_eq!(summary, "Finished auth callback recovery");
|
||||||
|
assert_eq!(details, vec!["tests_run=2"]);
|
||||||
|
assert!(json);
|
||||||
|
}
|
||||||
|
_ => panic!("expected graph add-observation subcommand"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_decisions_human_renders_details() {
|
fn format_decisions_human_renders_details() {
|
||||||
let text = format_decisions_human(
|
let text = format_decisions_human(
|
||||||
@@ -4334,17 +4501,39 @@ mod tests {
|
|||||||
"recovery".to_string(),
|
"recovery".to_string(),
|
||||||
],
|
],
|
||||||
relation_count: 2,
|
relation_count: 2,
|
||||||
|
observation_count: 1,
|
||||||
}],
|
}],
|
||||||
Some("sess-12345678"),
|
Some("sess-12345678"),
|
||||||
"auth callback recovery",
|
"auth callback recovery",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(text.contains("Relevant memory: 1 entries"));
|
assert!(text.contains("Relevant memory: 1 entries"));
|
||||||
assert!(text.contains("[file] callback.ts | score 319 | relations 2"));
|
assert!(text.contains("[file] callback.ts | score 319 | relations 2 | observations 1"));
|
||||||
assert!(text.contains("matches auth, callback, recovery"));
|
assert!(text.contains("matches auth, callback, recovery"));
|
||||||
assert!(text.contains("path src/routes/auth/callback.ts"));
|
assert!(text.contains("path src/routes/auth/callback.ts"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_graph_observations_human_renders_summaries() {
|
||||||
|
let text = format_graph_observations_human(&[session::ContextGraphObservation {
|
||||||
|
id: 5,
|
||||||
|
session_id: Some("sess-12345678".to_string()),
|
||||||
|
entity_id: 11,
|
||||||
|
entity_type: "session".to_string(),
|
||||||
|
entity_name: "sess-12345678".to_string(),
|
||||||
|
observation_type: "completion_summary".to_string(),
|
||||||
|
summary: "Finished auth callback recovery with 2 tests".to_string(),
|
||||||
|
details: BTreeMap::from([("tests_run".to_string(), "2".to_string())]),
|
||||||
|
created_at: chrono::DateTime::parse_from_rfc3339("2026-04-10T01:02:03Z")
|
||||||
|
.unwrap()
|
||||||
|
.with_timezone(&chrono::Utc),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
assert!(text.contains("Context graph observations: 1"));
|
||||||
|
assert!(text.contains("[completion_summary] sess-12345678"));
|
||||||
|
assert!(text.contains("summary Finished auth callback recovery with 2 tests"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn format_graph_sync_stats_human_renders_counts() {
|
fn format_graph_sync_stats_human_renders_counts() {
|
||||||
let text = format_graph_sync_stats_human(
|
let text = format_graph_sync_stats_human(
|
||||||
|
|||||||
@@ -190,12 +190,26 @@ pub struct ContextGraphEntityDetail {
|
|||||||
pub incoming: Vec<ContextGraphRelation>,
|
pub incoming: Vec<ContextGraphRelation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct ContextGraphObservation {
|
||||||
|
pub id: i64,
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
pub entity_id: i64,
|
||||||
|
pub entity_type: String,
|
||||||
|
pub entity_name: String,
|
||||||
|
pub observation_type: String,
|
||||||
|
pub summary: String,
|
||||||
|
pub details: BTreeMap<String, String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct ContextGraphRecallEntry {
|
pub struct ContextGraphRecallEntry {
|
||||||
pub entity: ContextGraphEntity,
|
pub entity: ContextGraphEntity,
|
||||||
pub score: u64,
|
pub score: u64,
|
||||||
pub matched_terms: Vec<String>,
|
pub matched_terms: Vec<String>,
|
||||||
pub relation_count: usize,
|
pub relation_count: usize,
|
||||||
|
pub observation_count: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ use crate::observability::{ToolCallEvent, ToolLogEntry, ToolLogPage};
|
|||||||
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
use super::output::{OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT};
|
||||||
use super::{
|
use super::{
|
||||||
default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity,
|
default_project_label, default_task_group_label, normalize_group_label, ContextGraphEntity,
|
||||||
ContextGraphEntityDetail, ContextGraphRecallEntry, ContextGraphRelation, ContextGraphSyncStats,
|
ContextGraphEntityDetail, ContextGraphObservation, ContextGraphRecallEntry,
|
||||||
DecisionLogEntry, FileActivityAction, FileActivityEntry, Session, SessionAgentProfile,
|
ContextGraphRelation, ContextGraphSyncStats, DecisionLogEntry, FileActivityAction,
|
||||||
SessionMessage, SessionMetrics, SessionState, WorktreeInfo,
|
FileActivityEntry, Session, SessionAgentProfile, SessionMessage, SessionMetrics, SessionState,
|
||||||
|
WorktreeInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct StateStore {
|
pub struct StateStore {
|
||||||
@@ -259,6 +260,16 @@ impl StateStore {
|
|||||||
UNIQUE(from_entity_id, to_entity_id, relation_type)
|
UNIQUE(from_entity_id, to_entity_id, relation_type)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS context_graph_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
|
||||||
|
entity_id INTEGER NOT NULL REFERENCES context_graph_entities(id) ON DELETE CASCADE,
|
||||||
|
observation_type TEXT NOT NULL,
|
||||||
|
summary TEXT NOT NULL,
|
||||||
|
details_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS pending_worktree_queue (
|
CREATE TABLE IF NOT EXISTS pending_worktree_queue (
|
||||||
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
|
||||||
repo_root TEXT NOT NULL,
|
repo_root TEXT NOT NULL,
|
||||||
@@ -319,6 +330,8 @@ impl StateStore {
|
|||||||
ON context_graph_relations(from_entity_id, created_at, id);
|
ON context_graph_relations(from_entity_id, created_at, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to
|
CREATE INDEX IF NOT EXISTS idx_context_graph_relations_to
|
||||||
ON context_graph_relations(to_entity_id, created_at, id);
|
ON context_graph_relations(to_entity_id, created_at, id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_context_graph_observations_entity
|
||||||
|
ON context_graph_observations(entity_id, created_at, id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions
|
CREATE INDEX IF NOT EXISTS idx_conflict_incidents_sessions
|
||||||
ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at);
|
ON conflict_incidents(first_session_id, second_session_id, resolved_at, updated_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at
|
CREATE INDEX IF NOT EXISTS idx_pending_worktree_queue_requested_at
|
||||||
@@ -2047,7 +2060,22 @@ impl StateStore {
|
|||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM context_graph_relations r
|
FROM context_graph_relations r
|
||||||
WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id
|
WHERE r.from_entity_id = e.id OR r.to_entity_id = e.id
|
||||||
) AS relation_count
|
) AS relation_count,
|
||||||
|
COALESCE((
|
||||||
|
SELECT group_concat(summary, ' ')
|
||||||
|
FROM (
|
||||||
|
SELECT summary
|
||||||
|
FROM context_graph_observations o
|
||||||
|
WHERE o.entity_id = e.id
|
||||||
|
ORDER BY o.created_at DESC, o.id DESC
|
||||||
|
LIMIT 4
|
||||||
|
)
|
||||||
|
), '') AS observation_text,
|
||||||
|
(
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM context_graph_observations o
|
||||||
|
WHERE o.entity_id = e.id
|
||||||
|
) AS observation_count
|
||||||
FROM context_graph_entities e
|
FROM context_graph_entities e
|
||||||
WHERE (?1 IS NULL OR e.session_id = ?1)
|
WHERE (?1 IS NULL OR e.session_id = ?1)
|
||||||
ORDER BY e.updated_at DESC, e.id DESC
|
ORDER BY e.updated_at DESC, e.id DESC
|
||||||
@@ -2060,7 +2088,9 @@ impl StateStore {
|
|||||||
|row| {
|
|row| {
|
||||||
let entity = map_context_graph_entity(row)?;
|
let entity = map_context_graph_entity(row)?;
|
||||||
let relation_count = row.get::<_, i64>(9)?.max(0) as usize;
|
let relation_count = row.get::<_, i64>(9)?.max(0) as usize;
|
||||||
Ok((entity, relation_count))
|
let observation_text = row.get::<_, String>(10)?;
|
||||||
|
let observation_count = row.get::<_, i64>(11)?.max(0) as usize;
|
||||||
|
Ok((entity, relation_count, observation_text, observation_count))
|
||||||
},
|
},
|
||||||
)?
|
)?
|
||||||
.collect::<Result<Vec<_>, _>>()?;
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
@@ -2068,24 +2098,29 @@ impl StateStore {
|
|||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let mut entries = candidates
|
let mut entries = candidates
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|(entity, relation_count)| {
|
.filter_map(
|
||||||
let matched_terms = context_graph_matched_terms(&entity, &terms);
|
|(entity, relation_count, observation_text, observation_count)| {
|
||||||
if matched_terms.is_empty() {
|
let matched_terms =
|
||||||
return None;
|
context_graph_matched_terms(&entity, &observation_text, &terms);
|
||||||
}
|
if matched_terms.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
Some(ContextGraphRecallEntry {
|
Some(ContextGraphRecallEntry {
|
||||||
score: context_graph_recall_score(
|
score: context_graph_recall_score(
|
||||||
matched_terms.len(),
|
matched_terms.len(),
|
||||||
|
relation_count,
|
||||||
|
observation_count,
|
||||||
|
entity.updated_at,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
entity,
|
||||||
|
matched_terms,
|
||||||
relation_count,
|
relation_count,
|
||||||
entity.updated_at,
|
observation_count,
|
||||||
now,
|
})
|
||||||
),
|
},
|
||||||
entity,
|
)
|
||||||
matched_terms,
|
|
||||||
relation_count,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
entries.sort_by(|left, right| {
|
entries.sort_by(|left, right| {
|
||||||
@@ -2165,6 +2200,95 @@ impl StateStore {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_context_observation(
|
||||||
|
&self,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
entity_id: i64,
|
||||||
|
observation_type: &str,
|
||||||
|
summary: &str,
|
||||||
|
details: &BTreeMap<String, String>,
|
||||||
|
) -> Result<ContextGraphObservation> {
|
||||||
|
if observation_type.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Context graph observation type cannot be empty"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if summary.trim().is_empty() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"Context graph observation summary cannot be empty"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
let details_json = serde_json::to_string(details)?;
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO context_graph_observations (
|
||||||
|
session_id, entity_id, observation_type, summary, details_json, created_at
|
||||||
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||||
|
rusqlite::params![
|
||||||
|
session_id,
|
||||||
|
entity_id,
|
||||||
|
observation_type.trim(),
|
||||||
|
summary.trim(),
|
||||||
|
details_json,
|
||||||
|
now,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let observation_id = self.conn.last_insert_rowid();
|
||||||
|
self.conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name,
|
||||||
|
o.observation_type, o.summary, o.details_json, o.created_at
|
||||||
|
FROM context_graph_observations o
|
||||||
|
JOIN context_graph_entities e ON e.id = o.entity_id
|
||||||
|
WHERE o.id = ?1",
|
||||||
|
rusqlite::params![observation_id],
|
||||||
|
map_context_graph_observation,
|
||||||
|
)
|
||||||
|
.map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_session_observation(
|
||||||
|
&self,
|
||||||
|
session_id: &str,
|
||||||
|
observation_type: &str,
|
||||||
|
summary: &str,
|
||||||
|
details: &BTreeMap<String, String>,
|
||||||
|
) -> Result<ContextGraphObservation> {
|
||||||
|
let session_entity = self.sync_context_graph_session(session_id)?;
|
||||||
|
self.add_context_observation(
|
||||||
|
Some(session_id),
|
||||||
|
session_entity.id,
|
||||||
|
observation_type,
|
||||||
|
summary,
|
||||||
|
details,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_context_observations(
|
||||||
|
&self,
|
||||||
|
entity_id: Option<i64>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<ContextGraphObservation>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT o.id, o.session_id, o.entity_id, e.entity_type, e.name,
|
||||||
|
o.observation_type, o.summary, o.details_json, o.created_at
|
||||||
|
FROM context_graph_observations o
|
||||||
|
JOIN context_graph_entities e ON e.id = o.entity_id
|
||||||
|
WHERE (?1 IS NULL OR o.entity_id = ?1)
|
||||||
|
ORDER BY o.created_at DESC, o.id DESC
|
||||||
|
LIMIT ?2",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let entries = stmt
|
||||||
|
.query_map(
|
||||||
|
rusqlite::params![entity_id, limit as i64],
|
||||||
|
map_context_graph_observation,
|
||||||
|
)?
|
||||||
|
.collect::<Result<Vec<_>, _>>()?;
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn upsert_context_relation(
|
pub fn upsert_context_relation(
|
||||||
&self,
|
&self,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
@@ -3147,6 +3271,30 @@ fn map_context_graph_relation(row: &rusqlite::Row<'_>) -> rusqlite::Result<Conte
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn map_context_graph_observation(
|
||||||
|
row: &rusqlite::Row<'_>,
|
||||||
|
) -> rusqlite::Result<ContextGraphObservation> {
|
||||||
|
let details_json = row
|
||||||
|
.get::<_, Option<String>>(7)?
|
||||||
|
.unwrap_or_else(|| "{}".to_string());
|
||||||
|
let details = serde_json::from_str(&details_json).map_err(|error| {
|
||||||
|
rusqlite::Error::FromSqlConversionFailure(7, rusqlite::types::Type::Text, Box::new(error))
|
||||||
|
})?;
|
||||||
|
let created_at = parse_store_timestamp(row.get::<_, String>(8)?, 8)?;
|
||||||
|
|
||||||
|
Ok(ContextGraphObservation {
|
||||||
|
id: row.get(0)?,
|
||||||
|
session_id: row.get(1)?,
|
||||||
|
entity_id: row.get(2)?,
|
||||||
|
entity_type: row.get(3)?,
|
||||||
|
entity_name: row.get(4)?,
|
||||||
|
observation_type: row.get(5)?,
|
||||||
|
summary: row.get(6)?,
|
||||||
|
details,
|
||||||
|
created_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn context_graph_recall_terms(query: &str) -> Vec<String> {
|
fn context_graph_recall_terms(query: &str) -> Vec<String> {
|
||||||
let mut terms = Vec::new();
|
let mut terms = Vec::new();
|
||||||
for raw_term in
|
for raw_term in
|
||||||
@@ -3161,7 +3309,11 @@ fn context_graph_recall_terms(query: &str) -> Vec<String> {
|
|||||||
terms
|
terms
|
||||||
}
|
}
|
||||||
|
|
||||||
fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) -> Vec<String> {
|
fn context_graph_matched_terms(
|
||||||
|
entity: &ContextGraphEntity,
|
||||||
|
observation_text: &str,
|
||||||
|
terms: &[String],
|
||||||
|
) -> Vec<String> {
|
||||||
let mut haystacks = vec![
|
let mut haystacks = vec![
|
||||||
entity.entity_type.to_ascii_lowercase(),
|
entity.entity_type.to_ascii_lowercase(),
|
||||||
entity.name.to_ascii_lowercase(),
|
entity.name.to_ascii_lowercase(),
|
||||||
@@ -3174,6 +3326,9 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) ->
|
|||||||
haystacks.push(key.to_ascii_lowercase());
|
haystacks.push(key.to_ascii_lowercase());
|
||||||
haystacks.push(value.to_ascii_lowercase());
|
haystacks.push(value.to_ascii_lowercase());
|
||||||
}
|
}
|
||||||
|
if !observation_text.trim().is_empty() {
|
||||||
|
haystacks.push(observation_text.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
|
||||||
let mut matched = Vec::new();
|
let mut matched = Vec::new();
|
||||||
for term in terms {
|
for term in terms {
|
||||||
@@ -3187,6 +3342,7 @@ fn context_graph_matched_terms(entity: &ContextGraphEntity, terms: &[String]) ->
|
|||||||
fn context_graph_recall_score(
|
fn context_graph_recall_score(
|
||||||
matched_term_count: usize,
|
matched_term_count: usize,
|
||||||
relation_count: usize,
|
relation_count: usize,
|
||||||
|
observation_count: usize,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
now: chrono::DateTime<chrono::Utc>,
|
now: chrono::DateTime<chrono::Utc>,
|
||||||
) -> u64 {
|
) -> u64 {
|
||||||
@@ -3203,7 +3359,10 @@ fn context_graph_recall_score(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
(matched_term_count as u64 * 100) + (relation_count.min(9) as u64 * 10) + recency_bonus
|
(matched_term_count as u64 * 100)
|
||||||
|
+ (relation_count.min(9) as u64 * 10)
|
||||||
|
+ (observation_count.min(6) as u64 * 8)
|
||||||
|
+ recency_bonus
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_store_timestamp(
|
fn parse_store_timestamp(
|
||||||
@@ -3990,6 +4149,57 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn add_and_list_context_observations() -> Result<()> {
|
||||||
|
let tempdir = TestDir::new("store-context-observations")?;
|
||||||
|
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
db.insert_session(&Session {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
task: "deep memory".to_string(),
|
||||||
|
project: "workspace".to_string(),
|
||||||
|
task_group: "knowledge".to_string(),
|
||||||
|
agent_type: "claude".to_string(),
|
||||||
|
working_dir: PathBuf::from("/tmp"),
|
||||||
|
state: SessionState::Running,
|
||||||
|
pid: None,
|
||||||
|
worktree: None,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
last_heartbeat_at: now,
|
||||||
|
metrics: SessionMetrics::default(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let entity = db.upsert_context_entity(
|
||||||
|
Some("session-1"),
|
||||||
|
"decision",
|
||||||
|
"Prefer recovery-first routing",
|
||||||
|
None,
|
||||||
|
"Recovered installs should go through the portal first",
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)?;
|
||||||
|
let observation = db.add_context_observation(
|
||||||
|
Some("session-1"),
|
||||||
|
entity.id,
|
||||||
|
"note",
|
||||||
|
"Customer wiped setup and got charged twice",
|
||||||
|
&BTreeMap::from([("customer".to_string(), "viktor".to_string())]),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let observations = db.list_context_observations(Some(entity.id), 10)?;
|
||||||
|
assert_eq!(observations.len(), 1);
|
||||||
|
assert_eq!(observations[0].id, observation.id);
|
||||||
|
assert_eq!(observations[0].entity_name, "Prefer recovery-first routing");
|
||||||
|
assert_eq!(observations[0].observation_type, "note");
|
||||||
|
assert_eq!(
|
||||||
|
observations[0].details.get("customer"),
|
||||||
|
Some(&"viktor".to_string())
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn recall_context_entities_ranks_matching_entities() -> Result<()> {
|
fn recall_context_entities_ranks_matching_entities() -> Result<()> {
|
||||||
let tempdir = TestDir::new("store-context-recall")?;
|
let tempdir = TestDir::new("store-context-recall")?;
|
||||||
@@ -4051,6 +4261,13 @@ mod tests {
|
|||||||
"references",
|
"references",
|
||||||
"Callback route references the dashboard summary",
|
"Callback route references the dashboard summary",
|
||||||
)?;
|
)?;
|
||||||
|
db.add_context_observation(
|
||||||
|
Some("session-1"),
|
||||||
|
recovery.id,
|
||||||
|
"incident_note",
|
||||||
|
"Previous auth callback recovery incident affected Viktor after a wipe",
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let results =
|
let results =
|
||||||
db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?;
|
db.recall_context_entities(Some("session-1"), "Investigate auth callback recovery", 3)?;
|
||||||
@@ -4068,6 +4285,7 @@ mod tests {
|
|||||||
.any(|term| term == "recovery"));
|
.any(|term| term == "recovery"));
|
||||||
assert_eq!(results[0].relation_count, 2);
|
assert_eq!(results[0].relation_count, 2);
|
||||||
assert_eq!(results[1].entity.id, recovery.id);
|
assert_eq!(results[1].entity.id, recovery.id);
|
||||||
|
assert_eq!(results[1].observation_count, 1);
|
||||||
assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id));
|
assert!(!results.iter().any(|entry| entry.entity.id == unrelated.id));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -4153,6 +4153,11 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
SessionState::Completed => {
|
SessionState::Completed => {
|
||||||
let summary = self.build_completion_summary(session);
|
let summary = self.build_completion_summary(session);
|
||||||
|
self.persist_completion_summary_observation(
|
||||||
|
session,
|
||||||
|
&summary,
|
||||||
|
"completion_summary",
|
||||||
|
);
|
||||||
if self.cfg.completion_summary_notifications.enabled {
|
if self.cfg.completion_summary_notifications.enabled {
|
||||||
completion_summaries.push(summary.clone());
|
completion_summaries.push(summary.clone());
|
||||||
} else if self.cfg.desktop_notifications.session_completed {
|
} else if self.cfg.desktop_notifications.session_completed {
|
||||||
@@ -4174,6 +4179,11 @@ impl Dashboard {
|
|||||||
}
|
}
|
||||||
SessionState::Failed => {
|
SessionState::Failed => {
|
||||||
let summary = self.build_completion_summary(session);
|
let summary = self.build_completion_summary(session);
|
||||||
|
self.persist_completion_summary_observation(
|
||||||
|
session,
|
||||||
|
&summary,
|
||||||
|
"failure_summary",
|
||||||
|
);
|
||||||
failed_notifications.push((
|
failed_notifications.push((
|
||||||
"ECC 2.0: Session failed".to_string(),
|
"ECC 2.0: Session failed".to_string(),
|
||||||
format!(
|
format!(
|
||||||
@@ -4226,6 +4236,34 @@ impl Dashboard {
|
|||||||
self.last_session_states = next_states;
|
self.last_session_states = next_states;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn persist_completion_summary_observation(
|
||||||
|
&self,
|
||||||
|
session: &Session,
|
||||||
|
summary: &SessionCompletionSummary,
|
||||||
|
observation_type: &str,
|
||||||
|
) {
|
||||||
|
let observation_summary = format!(
|
||||||
|
"{} | files {} | tests {}/{} | warnings {}",
|
||||||
|
truncate_for_dashboard(&summary.task, 72),
|
||||||
|
summary.files_changed,
|
||||||
|
summary.tests_passed,
|
||||||
|
summary.tests_run,
|
||||||
|
summary.warnings.len()
|
||||||
|
);
|
||||||
|
let details = completion_summary_observation_details(summary, session);
|
||||||
|
if let Err(error) = self.db.add_session_observation(
|
||||||
|
&session.id,
|
||||||
|
observation_type,
|
||||||
|
&observation_summary,
|
||||||
|
&details,
|
||||||
|
) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Failed to persist completion observation for {}: {error}",
|
||||||
|
session.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn sync_approval_notifications(&mut self) {
|
fn sync_approval_notifications(&mut self) {
|
||||||
let latest_message = match self.db.latest_unread_approval_message() {
|
let latest_message = match self.db.latest_unread_approval_message() {
|
||||||
Ok(message) => message,
|
Ok(message) => message,
|
||||||
@@ -5320,12 +5358,13 @@ impl Dashboard {
|
|||||||
let mut lines = vec!["Relevant memory".to_string()];
|
let mut lines = vec!["Relevant memory".to_string()];
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
let mut line = format!(
|
let mut line = format!(
|
||||||
"- #{} [{}] {} | score {} | relations {}",
|
"- #{} [{}] {} | score {} | relations {} | observations {}",
|
||||||
entry.entity.id,
|
entry.entity.id,
|
||||||
entry.entity.entity_type,
|
entry.entity.entity_type,
|
||||||
truncate_for_dashboard(&entry.entity.name, 60),
|
truncate_for_dashboard(&entry.entity.name, 60),
|
||||||
entry.score,
|
entry.score,
|
||||||
entry.relation_count
|
entry.relation_count,
|
||||||
|
entry.observation_count
|
||||||
);
|
);
|
||||||
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
if let Some(session_id) = entry.entity.session_id.as_deref() {
|
||||||
if session_id != session.id {
|
if session_id != session.id {
|
||||||
@@ -5345,6 +5384,14 @@ impl Dashboard {
|
|||||||
truncate_for_dashboard(&entry.entity.summary, 72)
|
truncate_for_dashboard(&entry.entity.summary, 72)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if let Ok(observations) = self.db.list_context_observations(Some(entry.entity.id), 1) {
|
||||||
|
if let Some(observation) = observations.first() {
|
||||||
|
lines.push(format!(
|
||||||
|
" memory {}",
|
||||||
|
truncate_for_dashboard(&observation.summary, 72)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lines
|
lines
|
||||||
@@ -8517,6 +8564,39 @@ fn summarize_completion_warnings(
|
|||||||
warnings
|
warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn completion_summary_observation_details(
|
||||||
|
summary: &SessionCompletionSummary,
|
||||||
|
session: &Session,
|
||||||
|
) -> BTreeMap<String, String> {
|
||||||
|
let mut details = BTreeMap::new();
|
||||||
|
details.insert("state".to_string(), session.state.to_string());
|
||||||
|
details.insert(
|
||||||
|
"files_changed".to_string(),
|
||||||
|
summary.files_changed.to_string(),
|
||||||
|
);
|
||||||
|
details.insert("tokens_used".to_string(), summary.tokens_used.to_string());
|
||||||
|
details.insert(
|
||||||
|
"duration_secs".to_string(),
|
||||||
|
summary.duration_secs.to_string(),
|
||||||
|
);
|
||||||
|
details.insert("cost_usd".to_string(), format!("{:.4}", summary.cost_usd));
|
||||||
|
details.insert("tests_run".to_string(), summary.tests_run.to_string());
|
||||||
|
details.insert("tests_passed".to_string(), summary.tests_passed.to_string());
|
||||||
|
if !summary.recent_files.is_empty() {
|
||||||
|
details.insert("recent_files".to_string(), summary.recent_files.join(" | "));
|
||||||
|
}
|
||||||
|
if !summary.key_decisions.is_empty() {
|
||||||
|
details.insert(
|
||||||
|
"key_decisions".to_string(),
|
||||||
|
summary.key_decisions.join(" | "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !summary.warnings.is_empty() {
|
||||||
|
details.insert("warnings".to_string(), summary.warnings.join(" | "));
|
||||||
|
}
|
||||||
|
details
|
||||||
|
}
|
||||||
|
|
||||||
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
|
fn session_started_webhook_body(session: &Session, compare_url: Option<&str>) -> String {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
"*ECC 2.0: Session started*".to_string(),
|
"*ECC 2.0: Session started*".to_string(),
|
||||||
@@ -10444,11 +10524,25 @@ diff --git a/src/lib.rs b/src/lib.rs\n\
|
|||||||
"Handles auth callback recovery and billing fallback",
|
"Handles auth callback recovery and billing fallback",
|
||||||
&BTreeMap::from([("area".to_string(), "auth".to_string())]),
|
&BTreeMap::from([("area".to_string(), "auth".to_string())]),
|
||||||
)?;
|
)?;
|
||||||
|
let entity = dashboard
|
||||||
|
.db
|
||||||
|
.list_context_entities(Some(&memory.id), Some("file"), 10)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|entry| entry.name == "callback.ts")
|
||||||
|
.expect("callback entity");
|
||||||
|
dashboard.db.add_context_observation(
|
||||||
|
Some(&memory.id),
|
||||||
|
entity.id,
|
||||||
|
"completion_summary",
|
||||||
|
"Recovered auth callback incident with billing fallback",
|
||||||
|
&BTreeMap::new(),
|
||||||
|
)?;
|
||||||
|
|
||||||
let text = dashboard.selected_session_metrics_text();
|
let text = dashboard.selected_session_metrics_text();
|
||||||
assert!(text.contains("Relevant memory"));
|
assert!(text.contains("Relevant memory"));
|
||||||
assert!(text.contains("[file] callback.ts"));
|
assert!(text.contains("[file] callback.ts"));
|
||||||
assert!(text.contains("matches auth, callback, recovery"));
|
assert!(text.contains("matches auth, callback, recovery"));
|
||||||
|
assert!(text.contains("memory Recovered auth callback incident with billing fallback"));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11876,6 +11970,73 @@ diff --git a/src/lib.rs b/src/lib.rs
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refresh_persists_completion_summary_observation() -> Result<()> {
|
||||||
|
let root =
|
||||||
|
std::env::temp_dir().join(format!("ecc2-completion-observation-{}", Uuid::new_v4()));
|
||||||
|
fs::create_dir_all(root.join(".claude").join("metrics"))?;
|
||||||
|
|
||||||
|
let mut cfg = build_config(&root.join(".claude"));
|
||||||
|
cfg.completion_summary_notifications.delivery =
|
||||||
|
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||||
|
cfg.desktop_notifications.session_completed = false;
|
||||||
|
|
||||||
|
let db = StateStore::open(&cfg.db_path)?;
|
||||||
|
let mut session = sample_session(
|
||||||
|
"done-observation",
|
||||||
|
"claude",
|
||||||
|
SessionState::Running,
|
||||||
|
Some("ecc/observation"),
|
||||||
|
144,
|
||||||
|
42,
|
||||||
|
);
|
||||||
|
session.task = "Recover auth callback after wipe".to_string();
|
||||||
|
db.insert_session(&session)?;
|
||||||
|
|
||||||
|
let metrics_path = cfg.tool_activity_metrics_path();
|
||||||
|
fs::create_dir_all(metrics_path.parent().unwrap())?;
|
||||||
|
fs::write(
|
||||||
|
&metrics_path,
|
||||||
|
concat!(
|
||||||
|
"{\"id\":\"evt-1\",\"session_id\":\"done-observation\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||||
|
"{\"id\":\"evt-2\",\"session_id\":\"done-observation\",\"tool_name\":\"Write\",\"input_summary\":\"Write src/routes/auth/callback.ts\",\"output_summary\":\"updated callback\",\"file_events\":[{\"path\":\"src/routes/auth/callback.ts\",\"action\":\"modify\",\"diff_preview\":\"portal first\",\"patch_preview\":\"+ portal first\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n"
|
||||||
|
),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let mut dashboard = Dashboard::new(db, cfg);
|
||||||
|
dashboard
|
||||||
|
.db
|
||||||
|
.update_state("done-observation", &SessionState::Completed)?;
|
||||||
|
|
||||||
|
dashboard.refresh();
|
||||||
|
|
||||||
|
let session_entity = dashboard
|
||||||
|
.db
|
||||||
|
.list_context_entities(Some("done-observation"), Some("session"), 10)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|entity| entity.name == "done-observation")
|
||||||
|
.expect("session entity");
|
||||||
|
let observations = dashboard
|
||||||
|
.db
|
||||||
|
.list_context_observations(Some(session_entity.id), 10)?;
|
||||||
|
assert!(!observations.is_empty());
|
||||||
|
assert_eq!(observations[0].observation_type, "completion_summary");
|
||||||
|
assert!(observations[0]
|
||||||
|
.summary
|
||||||
|
.contains("Recover auth callback after wipe"));
|
||||||
|
assert_eq!(
|
||||||
|
observations[0].details.get("tests_run"),
|
||||||
|
Some(&"1".to_string())
|
||||||
|
);
|
||||||
|
assert!(observations[0]
|
||||||
|
.details
|
||||||
|
.get("recent_files")
|
||||||
|
.is_some_and(|value| value.contains("modify src/routes/auth/callback.ts")));
|
||||||
|
|
||||||
|
let _ = fs::remove_dir_all(root);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dismiss_completion_popup_promotes_the_next_summary() {
|
fn dismiss_completion_popup_promotes_the_next_summary() {
|
||||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user