From 63bbbae17c4217c9d1a44b5251e1fb1501fe645c Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Tue, 10 Mar 2026 19:03:47 +0000 Subject: [PATCH] feat: show plan diff in execution log when revise_plan is called - apply_plan_diff now returns a YAML unified diff string - Pure Rust LCS diff implementation (no external dependency) - revise_plan logs the diff to execution log with ```diff fencing - Frontend renders diff with green/red syntax highlighting --- src/agent.rs | 6 ++- src/state.rs | 66 ++++++++++++++++++++++++- web/src/components/ExecutionSection.vue | 35 ++++++++++++- 3 files changed, 104 insertions(+), 3 deletions(-) diff --git a/src/agent.rs b/src/agent.rs index 9362f4d..7845729 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -1124,7 +1124,7 @@ async fn process_feedback( }).collect(); // Apply docker-cache diff - state.apply_plan_diff(new_steps); + let diff = state.apply_plan_diff(new_steps); // Broadcast updated plan let _ = broadcast_tx.send(WsMessage::PlanUpdate { @@ -1134,6 +1134,10 @@ async fn process_feedback( tracing::info!("[workflow {}] Plan revised via feedback. First actionable: {:?}", workflow_id, state.first_actionable_step()); + + // Log the diff so frontend can show what changed + let diff_display = format!("```diff\n{}\n```", diff); + log_execution(pool, broadcast_tx, workflow_id, 0, "revise_plan", "计划变更", &diff_display, "done").await; } } } else { diff --git a/src/state.rs b/src/state.rs index e589599..160be5d 100644 --- a/src/state.rs +++ b/src/state.rs @@ -130,7 +130,23 @@ impl AgentState { /// Docker-build-cache 风格的 plan diff。 /// 比较 (title, description),user_feedbacks 不参与比较。 /// 第一个 mismatch 开始,该步骤及后续全部 invalidate → Pending。 - pub fn apply_plan_diff(&mut self, new_steps: Vec) { + /// Apply docker-cache style diff. Returns a unified-diff string (YAML format) + /// showing what changed, for logging in the frontend. + pub fn apply_plan_diff(&mut self, new_steps: Vec) -> String { + // Serialize old/new plans to YAML for diff (only title + description) + let to_yaml = |steps: &[Step]| -> String { + let items: Vec = steps.iter().map(|s| { + serde_json::json!({ + "step": s.order, + "title": s.title, + "description": s.description, + }) + }).collect(); + serde_yaml::to_string(&items).unwrap_or_default() + }; + let old_yaml = to_yaml(&self.steps); + let new_yaml = to_yaml(&new_steps); + let old = &self.steps; let mut result = Vec::new(); let mut invalidated = false; @@ -158,6 +174,9 @@ impl AgentState { } self.steps = result; + + // Generate unified diff + diff_strings(&old_yaml, &new_yaml) } /// 找到第一个需要执行的步骤 (Pending 或 Running)。 @@ -255,6 +274,51 @@ impl AgentState { } } +/// Simple line-by-line unified diff (no external dependency). +/// Uses longest common subsequence to produce a clean diff. +fn diff_strings(old: &str, new: &str) -> String { + let old_lines: Vec<&str> = old.lines().collect(); + let new_lines: Vec<&str> = new.lines().collect(); + + if old_lines == new_lines { + return String::from("(no changes)"); + } + + // LCS table + let m = old_lines.len(); + let n = new_lines.len(); + let mut dp = vec![vec![0u32; n + 1]; m + 1]; + for i in 1..=m { + for j in 1..=n { + dp[i][j] = if old_lines[i - 1] == new_lines[j - 1] { + dp[i - 1][j - 1] + 1 + } else { + dp[i - 1][j].max(dp[i][j - 1]) + }; + } + } + + // Backtrack to produce diff lines + let mut result = Vec::new(); + let (mut i, mut j) = (m, n); + while i > 0 || j > 0 { + if i > 0 && j > 0 && old_lines[i - 1] == new_lines[j - 1] { + result.push(format!(" {}", old_lines[i - 1])); + i -= 1; + j -= 1; + } else if j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j]) { + result.push(format!("+{}", new_lines[j - 1])); + j -= 1; + } else { + result.push(format!("-{}", old_lines[i - 1])); + i -= 1; + } + } + + result.reverse(); + result.join("\n") +} + #[cfg(test)] mod tests { use super::*; diff --git a/web/src/components/ExecutionSection.vue b/web/src/components/ExecutionSection.vue index 48b5a0e..4945ad0 100644 --- a/web/src/components/ExecutionSection.vue +++ b/web/src/components/ExecutionSection.vue @@ -131,6 +131,25 @@ function formatLatency(ms: number): string { return ms + 'ms' } +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>') +} + +function isDiffOutput(output: string): boolean { + return output.startsWith('```diff\n') +} + +function renderDiff(output: string): string { + // Strip ```diff fences + const inner = output.replace(/^```diff\n/, '').replace(/\n```$/, '') + return inner.split('\n').map(line => { + const esc = escapeHtml(line) + if (line.startsWith('+')) return `${esc}` + if (line.startsWith('-')) return `${esc}` + return esc + }).join('\n') +} + function parseToolCalls(json: string): { name: string; arguments_preview: string }[] { try { return JSON.parse(json) @@ -275,7 +294,7 @@ watch(logItems, () => {
{{ item.entry.tool_input }}
-
{{ item.entry.output }}
+

           
         
       
@@ -665,4 +684,18 @@ watch(logItems, () => {
   font-family: 'JetBrains Mono', 'Fira Code', monospace;
   font-size: 10px;
 }
+
+pre.diff-output .diff-add {
+  color: #22c55e;
+  background: rgba(34, 197, 94, 0.1);
+  display: inline-block;
+  width: 100%;
+}
+
+pre.diff-output .diff-del {
+  color: #ef4444;
+  background: rgba(239, 68, 68, 0.1);
+  display: inline-block;
+  width: 100%;
+}