feat: Implement lesson generation feature and update status display

- Added functionality to generate lessons based on code reviews, including prompts and instructions for the reviewer.
- Updated the `.gitignore` to exclude lesson documentation files.
- Removed the `/lesson` command from the help menu and command parsing.
- Enhanced the status bar to display a message when a lesson is being generated.
- Introduced a new `lesson_running` state to track lesson generation progress.
- Updated various components to handle the new lesson generation workflow, including subagent events and token usage tracking.
This commit is contained in:
asepharyana
2026-07-15 03:11:55 +07:00
parent f87ab133f1
commit 1c08b8e4e9
18 changed files with 468 additions and 256 deletions
+112 -40
View File
@@ -287,6 +287,71 @@ fn truncate_output(s: &str, max: usize) -> String {
}
}
/// Spawn a background quality-review subagent for the current session.
///
/// Flow: build a "quality-reviewer" subagent context → probe build/test
/// status via `probe_build_test` to give the reviewer a real pass/fail
/// signal → compose a system prompt embedding the probe result and lesson
/// tagging instructions → spawn a thread running `run_subagent` → on
/// completion, push a `TurnEvent::SystemNote` with the verdict's first
/// line (or error) → push an "in progress" toast immediately.
///
/// Why: runs on a plain OS thread (not tokio) so it doesn't block the
/// async event loop; communicates its result back via `turn_events`
/// rather than a channel receiver (the `_rx` half is intentionally unused).
///
/// Return: `Ok(())` once the review has been kicked off; errors only
/// propagate from constructing the subagent context, not from the review
/// itself (that failure is reported via a `SystemNote` instead).
/// Compose the system prompt for the quality-review subagent.
fn compose_review_prompt(
state: &AppStateRest,
probe_note: &str,
) -> String {
let diff_output = if let Some(workspace) = state.workspace_roots.first() {
std::process::Command::new("git")
.arg("diff")
.arg("HEAD")
.current_dir(workspace)
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
} else {
String::new()
};
let history_output = if let Some(rt) = &state.session_runtime {
let msgs: Vec<String> = rt.messages.iter()
.filter(|m| m.role == crate::dto::chat::message::Role::Assistant || m.role == crate::dto::chat::message::Role::User)
.rev()
.take(10)
.map(|m| format!("{:?}: {}", m.role, m.content.as_deref().unwrap_or("")))
.collect();
let mut rev_msgs = msgs;
rev_msgs.reverse();
rev_msgs.join("\n\n")
} else {
String::new()
};
let session_dir_disp = state.session_dir.display();
format!(
"You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\
Session directory: {session_dir_disp}\n\n\
--- Build/Test Probe ---\n{probe_note}\n\n\
--- Recent Chat History (Last 10 messages) ---\n{history_output}\n\n\
--- Recent Code Diffs (git diff HEAD) ---\n{diff_output}\n\n\
INSTRUCTIONS:\n\
1. Compare the 'Recent Chat History' (what the AI promised or discussed) with the 'Recent Code Diffs' (what was actually changed).\n\
2. Ensure that the AI's promises match the actual code changes.\n\
3. Evaluate the code quality in the diff (check for best practices, clean code).\n\
4. Write your findings and learning points as a lesson to a file in `docs/lesson/` (e.g., docs/lesson/lesson_01.md).\n\
5. Use the `write` tool to save this markdown file.\n\
6. Your verdict should briefly summarize what lesson was created.",
)
}
/// Spawn a background quality-review subagent for the current session.
///
/// Flow: build a "quality-reviewer" subagent context → probe build/test
@@ -305,13 +370,36 @@ fn truncate_output(s: &str, max: usize) -> String {
/// itself (that failure is reported via a `SystemNote` instead).
#[allow(clippy::unnecessary_debug_formatting)]
pub fn trigger_review(state: &mut AppStateRest) {
let def = AgentDefinition::new(
"quality-reviewer".to_string(),
state.misc.lesson_running = true;
if let Some(workspace) = state.workspace_roots.first() {
let gitignore_path = workspace.join(".gitignore");
let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if !content.contains("docs/lesson") {
use std::io::Write;
if let Ok(mut file) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore_path) {
let prefix = if content.is_empty() || content.ends_with('\n') { "" } else { "\n" };
let _ = writeln!(file, "{prefix}docs/lesson/");
}
}
}
let mut def = AgentDefinition::new(
"lesson-generator".to_string(),
"reviewer".to_string(),
);
// Explicitly allow write_file for docs/lesson
def.allowed_tools = Some(vec![
"read".to_string(),
"write".to_string(),
"grep".to_string(),
"glob".to_string(),
]);
let mut ctx = build_subagent_context(&def);
ctx.session_dir.clone_from(&state.session_dir);
ctx.workspaces.clone_from(&state.workspace_roots);
let probe_result = probe_build_test(
&state.workspace_roots,
state.settings.verify_command.as_deref(),
@@ -321,60 +409,44 @@ pub fn trigger_review(state: &mut AppStateRest) {
let probe_note = match &probe_result {
Some(r) => {
if r.passed {
format!("Build/test verification passed ({}). Confidence: verified.", r.command)
format!("Build/test verification passed ({}).", r.command)
} else if r.timed_out {
format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command)
format!("Build/test verification timed out ({}).", r.command)
} else {
format!("Build/test verification failed ({}). Output: {}", r.command, r.output)
}
}
None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(),
None => "No build/test probe matched.".to_string(),
};
let session_dir = &state.session_dir;
ctx.system_prompt = format!(
"You are a code quality reviewer. Review the recent code changes \
for correctness, and adherence to best practices. \
Use read-only tools (read, grep, glob, recall, remember) to \
inspect the session files and provide a concise review verdict. \
Session directory: {session_dir:?}\n\n\
Build/Test Probe:\n{probe_note}\n\n\
When writing a lesson via remember(), set tags appropriately:\n\
- If build/test verification printed any FAILED/ERROR lines, tag\n\
the lesson as \"confidence: verified\" (backed by a real failure).\n\
- If the probe passed or was skipped, tag as \"confidence: opinion\"\n\
(reviewer judgment only).\n\
Check for duplicate lessons via recall before writing a new one.",
);
ctx.system_prompt = compose_review_prompt(state, &probe_note);
// Use a drain thread for subagent events (so blocking_send never
// fails on a closed channel) and log events at debug level for
// observability during review runs.
let turn_events_for_drain = state.turn_events.clone();
// Use a drain thread for subagent events
let (tx, rx) = tokio::sync::mpsc::channel(32);
let _drain_thread = std::thread::spawn(move || {
use crate::app::subagent::event::SubagentEvent;
let mut rx = rx;
while let Some(event) = rx.blocking_recv() {
match &event {
SubagentEvent::ToolCall { tool, .. } => {
tracing::debug!("[review] tool call: {}", tool);
}
SubagentEvent::ToolResult { tool, .. } => {
tracing::debug!("[review] tool result: {}", tool);
}
SubagentEvent::StepCompleted { .. } => {
tracing::trace!("[review] step completed");
}
SubagentEvent::StepFailed { step, error } => {
tracing::warn!("[review] step {} failed: {}", step, error);
}
SubagentEvent::ToolCall { tool, .. } => tracing::debug!("[review] tool call: {}", tool),
SubagentEvent::ToolResult { tool, .. } => tracing::debug!("[review] tool result: {}", tool),
SubagentEvent::StepCompleted { .. } => tracing::trace!("[review] step completed"),
SubagentEvent::StepFailed { step, error } => tracing::warn!("[review] step {} failed: {}", step, error),
SubagentEvent::Progress(_) => {}
SubagentEvent::Completed { .. } => {
tracing::debug!("[review] completed");
SubagentEvent::Completed { .. } => tracing::debug!("[review] completed"),
SubagentEvent::Usage { tokens_in, tokens_out } => {
if let Ok(mut q) = turn_events_for_drain.lock() {
q.push_back(TurnEvent::ReviewUsage {
tokens_in: *tokens_in,
tokens_out: *tokens_out,
});
}
}
}
}
});
let turn_events = state.turn_events.clone();
std::thread::spawn(move || {
@@ -382,9 +454,9 @@ pub fn trigger_review(state: &mut AppStateRest) {
let message = match result {
Ok(verdict) => {
let first_line = verdict.lines().next().unwrap_or(&verdict);
format!("Quality review: {first_line}")
format!("Lesson created: {first_line}")
}
Err(e) => format!("Quality review failed: {e}"),
Err(e) => format!("Lesson generation failed: {e}"),
};
if let Ok(mut q) = turn_events.lock() {
q.push_back(TurnEvent::SystemNote {
@@ -396,7 +468,7 @@ pub fn trigger_review(state: &mut AppStateRest) {
state.push_toast(Toast::new(
ToastKind::Info,
"Quality review triggered".to_string(),
"Generating lesson...".to_string(),
));
}