fix: add --all to cargo fmt in script/test and autoformat codebase
cargo fmt without --all fails with "Failed to find targets" in workspace repos. This was blocking every story's gates. Also ran cargo fmt --all to fix all existing formatting issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,10 @@ mod tests {
|
||||
// "timmy ambient on" — bot name mentioned but not @-prefixed, so
|
||||
// is_addressed is false; strip_bot_mention still strips "timmy ".
|
||||
let result = try_handle_command(&dispatch, "timmy ambient on");
|
||||
assert!(result.is_some(), "ambient on should fire even when is_addressed=false");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"ambient on should fire even when is_addressed=false"
|
||||
);
|
||||
assert!(
|
||||
ambient_rooms.lock().unwrap().contains(&room_id),
|
||||
"room should be in ambient_rooms after ambient on"
|
||||
@@ -92,7 +95,10 @@ mod tests {
|
||||
};
|
||||
// Bare "ambient off" in an ambient room (is_addressed=false).
|
||||
let result = try_handle_command(&dispatch, "ambient off");
|
||||
assert!(result.is_some(), "bare ambient off should be handled without LLM");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"bare ambient off should be handled without LLM"
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Ambient mode off"),
|
||||
@@ -161,7 +167,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn ambient_invalid_args_returns_usage() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy ambient");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy ambient",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Handler for the `backlog` command — shows only Stage::Backlog items.
|
||||
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
use super::CommandContext;
|
||||
use super::status::{story_short_label, unmet_deps_from_items};
|
||||
use crate::pipeline_state::{PipelineItem, Stage};
|
||||
|
||||
pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option<String> {
|
||||
Some(build_backlog_output())
|
||||
@@ -94,16 +94,29 @@ mod tests {
|
||||
make_item("30_story_in_qa", "In QA", Stage::Qa),
|
||||
];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(output.contains("In Backlog"), "should show backlog item: {output}");
|
||||
assert!(!output.contains("In Progress"), "should not show coding items: {output}");
|
||||
assert!(!output.contains("In QA"), "should not show QA items: {output}");
|
||||
assert!(
|
||||
output.contains("In Backlog"),
|
||||
"should show backlog item: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("In Progress"),
|
||||
"should not show coding items: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("In QA"),
|
||||
"should not show QA items: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC: shows number, type, name -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn backlog_shows_number_type_and_name() {
|
||||
let items = vec![make_item("42_story_my_feature", "My Feature", Stage::Backlog)];
|
||||
let items = vec![make_item(
|
||||
"42_story_my_feature",
|
||||
"My Feature",
|
||||
Stage::Backlog,
|
||||
)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(
|
||||
output.contains("42 [story] — My Feature"),
|
||||
@@ -116,7 +129,12 @@ mod tests {
|
||||
#[test]
|
||||
fn backlog_shows_waiting_on_for_unmet_deps() {
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Backlog, vec![999]),
|
||||
make_item_with_deps(
|
||||
"10_story_waiting",
|
||||
"Waiting Story",
|
||||
Stage::Backlog,
|
||||
vec![999],
|
||||
),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||
];
|
||||
let output = build_backlog_from_items(&items);
|
||||
@@ -150,16 +168,17 @@ mod tests {
|
||||
fn backlog_no_waiting_on_when_no_deps() {
|
||||
let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(!output.contains("waiting on"), "no dep suffix when no deps: {output}");
|
||||
assert!(
|
||||
!output.contains("waiting on"),
|
||||
"no dep suffix when no deps: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- AC: command is registered in the registry ----------------------------
|
||||
|
||||
#[test]
|
||||
fn backlog_command_in_registry() {
|
||||
let found = super::super::commands()
|
||||
.iter()
|
||||
.any(|c| c.name == "backlog");
|
||||
let found = super::super::commands().iter().any(|c| c.name == "backlog");
|
||||
assert!(found, "backlog must be registered in commands()");
|
||||
}
|
||||
|
||||
@@ -171,7 +190,10 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap_or_default();
|
||||
assert!(output.contains("backlog"), "backlog should appear in help output: {output}");
|
||||
assert!(
|
||||
output.contains("backlog"),
|
||||
"backlog should appear in help output: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,7 +203,10 @@ mod tests {
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy backlog",
|
||||
);
|
||||
assert!(result.is_some(), "backlog command should match and return Some");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"backlog command should match and return Some"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -192,7 +217,10 @@ mod tests {
|
||||
"@timmy backlog",
|
||||
);
|
||||
let output = result.unwrap_or_default();
|
||||
assert!(output.contains("Backlog"), "backlog output should contain Backlog header: {output}");
|
||||
assert!(
|
||||
output.contains("Backlog"),
|
||||
"backlog output should contain Backlog header: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- empty backlog --------------------------------------------------------
|
||||
@@ -201,6 +229,9 @@ mod tests {
|
||||
fn backlog_shows_none_when_empty() {
|
||||
let items = vec![make_item("1_story_done", "Done", Stage::Coding)];
|
||||
let output = build_backlog_from_items(&items);
|
||||
assert!(output.contains("*(none)*"), "should show none when no backlog items: {output}");
|
||||
assert!(
|
||||
output.contains("*(none)*"),
|
||||
"should show none when no backlog items: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::status::story_short_label;
|
||||
use super::CommandContext;
|
||||
use super::status::story_short_label;
|
||||
|
||||
/// Show token spend: 24h total, top 5 stories, agent-type breakdown, and
|
||||
/// all-time total.
|
||||
@@ -102,7 +102,10 @@ mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn write_token_records(root: &std::path::Path, records: &[crate::agents::token_usage::TokenUsageRecord]) {
|
||||
fn write_token_records(
|
||||
root: &std::path::Path,
|
||||
records: &[crate::agents::token_usage::TokenUsageRecord],
|
||||
) {
|
||||
for r in records {
|
||||
crate::agents::token_usage::append_record(root, r).unwrap();
|
||||
}
|
||||
@@ -118,7 +121,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_record(story_id: &str, agent_name: &str, cost: f64, hours_ago: i64) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
fn make_record(
|
||||
story_id: &str,
|
||||
agent_name: &str,
|
||||
cost: f64,
|
||||
hours_ago: i64,
|
||||
) -> crate::agents::token_usage::TokenUsageRecord {
|
||||
let ts = (chrono::Utc::now() - chrono::Duration::hours(hours_ago)).to_rfc3339();
|
||||
crate::agents::token_usage::TokenUsageRecord {
|
||||
story_id: story_id.to_string(),
|
||||
@@ -157,55 +165,89 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cost_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("cost"), "help should list cost command: {output}");
|
||||
assert!(
|
||||
output.contains("cost"),
|
||||
"help should list cost command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_no_records() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("No usage records found"), "should show empty message: {output}");
|
||||
assert!(
|
||||
output.contains("No usage records found"),
|
||||
"should show empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_24h_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.50, 2),
|
||||
make_record("42_story_foo", "coder-1", 0.50, 5),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $2.00"), "should show 24h total: {output}");
|
||||
assert!(
|
||||
output.contains("**Last 24h:** $2.00"),
|
||||
"should show 24h total: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_excludes_old_from_24h() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2), // within 24h
|
||||
make_record("43_story_bar", "coder-1", 5.00, 48), // older
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**Last 24h:** $1.00"), "should only count recent: {output}");
|
||||
assert!(output.contains("**All-time:** $6.00"), "all-time should include everything: {output}");
|
||||
assert!(
|
||||
output.contains("**Last 24h:** $1.00"),
|
||||
"should only count recent: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("**All-time:** $6.00"),
|
||||
"all-time should include everything: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_top_stories() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 3.00, 1),
|
||||
make_record("43_story_bar", "coder-1", 1.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 2.00, 1),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("Top Stories"), "should have top stories section: {output}");
|
||||
assert!(
|
||||
output.contains("Top Stories"),
|
||||
"should have top stories section: {output}"
|
||||
);
|
||||
// Story 42 ($5.00) should appear before story 43 ($1.00)
|
||||
let pos_42 = output.find("42").unwrap();
|
||||
let pos_43 = output.find("43").unwrap();
|
||||
assert!(pos_42 < pos_43, "story 42 should appear before 43 (sorted by cost): {output}");
|
||||
assert!(
|
||||
pos_42 < pos_43,
|
||||
"story 42 should appear before 43 (sorted by cost): {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,45 +255,75 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut records = Vec::new();
|
||||
for i in 1..=7 {
|
||||
records.push(make_record(&format!("{i}_story_s{i}"), "coder-1", i as f64, 1));
|
||||
records.push(make_record(
|
||||
&format!("{i}_story_s{i}"),
|
||||
"coder-1",
|
||||
i as f64,
|
||||
1,
|
||||
));
|
||||
}
|
||||
write_token_records(tmp.path(), &records);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
// The top 5 most expensive are stories 7,6,5,4,3. Stories 1 and 2 should be excluded.
|
||||
let top_section = output.split("**By Agent Type").next().unwrap();
|
||||
assert!(!top_section.contains("• 1 —"), "story 1 should not be in top 5: {output}");
|
||||
assert!(!top_section.contains("• 2 —"), "story 2 should not be in top 5: {output}");
|
||||
assert!(
|
||||
!top_section.contains("• 1 —"),
|
||||
"story 1 should not be in top 5: {output}"
|
||||
);
|
||||
assert!(
|
||||
!top_section.contains("• 2 —"),
|
||||
"story 2 should not be in top 5: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_agent_type_breakdown() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 2.00, 1),
|
||||
make_record("42_story_foo", "qa-1", 1.50, 1),
|
||||
make_record("42_story_foo", "mergemaster", 0.50, 1),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("By Agent Type"), "should have agent type section: {output}");
|
||||
assert!(
|
||||
output.contains("By Agent Type"),
|
||||
"should have agent type section: {output}"
|
||||
);
|
||||
assert!(output.contains("coder"), "should show coder type: {output}");
|
||||
assert!(output.contains("qa"), "should show qa type: {output}");
|
||||
assert!(output.contains("mergemaster"), "should show mergemaster type: {output}");
|
||||
assert!(
|
||||
output.contains("mergemaster"),
|
||||
"should show mergemaster type: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_shows_all_time_total() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
write_token_records(tmp.path(), &[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
]);
|
||||
write_token_records(
|
||||
tmp.path(),
|
||||
&[
|
||||
make_record("42_story_foo", "coder-1", 1.00, 2),
|
||||
make_record("43_story_bar", "coder-1", 9.00, 100),
|
||||
],
|
||||
);
|
||||
let output = cost_cmd_with_root(tmp.path()).unwrap();
|
||||
assert!(output.contains("**All-time:** $10.00"), "should show all-time total: {output}");
|
||||
assert!(
|
||||
output.contains("**All-time:** $10.00"),
|
||||
"should show all-time total: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cost_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy COST");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy COST",
|
||||
);
|
||||
assert!(result.is_some(), "COST should match case-insensitively");
|
||||
}
|
||||
|
||||
|
||||
@@ -59,12 +59,16 @@ fn read_cached_coverage(project_root: &std::path::Path) -> String {
|
||||
fn read_coverage_report(path: &std::path::Path) -> String {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}"),
|
||||
Err(e) => {
|
||||
return format!("**Coverage (cached)**\n\nError reading `.coverage_report.json`: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
let report: CoverageReport = match serde_json::from_str(&content) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return format!("**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}"),
|
||||
Err(e) => {
|
||||
return format!("**Coverage (cached)**\n\nFailed to parse `.coverage_report.json`: {e}");
|
||||
}
|
||||
};
|
||||
|
||||
format_coverage_report(&report)
|
||||
@@ -81,13 +85,22 @@ fn format_coverage_report(report: &CoverageReport) -> String {
|
||||
// Top 5 lowest-covered files (already sorted ascending in the JSON, but sort
|
||||
// defensively here so the display is correct even if the file was hand-edited).
|
||||
let mut sorted: Vec<&FileCoverage> = report.files.iter().collect();
|
||||
sorted.sort_by(|a, b| a.coverage.partial_cmp(&b.coverage).unwrap_or(std::cmp::Ordering::Equal));
|
||||
sorted.sort_by(|a, b| {
|
||||
a.coverage
|
||||
.partial_cmp(&b.coverage)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
let targets: Vec<&FileCoverage> = sorted.into_iter().take(5).collect();
|
||||
if !targets.is_empty() {
|
||||
out.push_str("\n**Top 5 files needing coverage:**\n");
|
||||
for (i, file) in targets.iter().enumerate() {
|
||||
out.push_str(&format!("{}. {} — {:.1}%\n", i + 1, file.path, file.coverage));
|
||||
out.push_str(&format!(
|
||||
"{}. {} — {:.1}%\n",
|
||||
i + 1,
|
||||
file.path,
|
||||
file.coverage
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +175,13 @@ fn run_coverage(project_root: &std::path::Path) -> String {
|
||||
// Replace the "cached" label with "fresh".
|
||||
result = result.replacen("Coverage (cached)", "Coverage (fresh)", 1);
|
||||
// Replace the cached hint with a pass/fail indicator.
|
||||
let pass_indicator = if out.status.success() { "PASS" } else { "FAIL: coverage below threshold" };
|
||||
result = result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
||||
let pass_indicator = if out.status.success() {
|
||||
"PASS"
|
||||
} else {
|
||||
"FAIL: coverage below threshold"
|
||||
};
|
||||
result =
|
||||
result.replacen("*Run `coverage run` for fresh results.*", pass_indicator, 1);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -322,9 +340,18 @@ mod tests {
|
||||
let output = handle_coverage(&ctx).unwrap();
|
||||
|
||||
assert!(output.contains("72.5"), "should include overall: {output}");
|
||||
assert!(output.contains("60.0"), "should include threshold: {output}");
|
||||
assert!(output.contains("15.0"), "should include lowest-covered file pct: {output}");
|
||||
assert!(output.contains("server/src/low.rs"), "should include lowest-covered file path: {output}");
|
||||
assert!(
|
||||
output.contains("60.0"),
|
||||
"should include threshold: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("15.0"),
|
||||
"should include lowest-covered file pct: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("server/src/low.rs"),
|
||||
"should include lowest-covered file path: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,9 +375,18 @@ mod tests {
|
||||
let output = handle_coverage(&ctx).unwrap();
|
||||
|
||||
assert!(output.contains("a.rs"), "should show lowest file: {output}");
|
||||
assert!(output.contains("e.rs"), "should show 5th lowest file: {output}");
|
||||
assert!(!output.contains("f.rs"), "should not show 6th file: {output}");
|
||||
assert!(!output.contains("g.rs"), "should not show 7th file: {output}");
|
||||
assert!(
|
||||
output.contains("e.rs"),
|
||||
"should show 5th lowest file: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("f.rs"),
|
||||
"should not show 6th file: {output}"
|
||||
);
|
||||
assert!(
|
||||
!output.contains("g.rs"),
|
||||
"should not show 7th file: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -466,15 +502,24 @@ mod tests {
|
||||
overall: 66.25,
|
||||
threshold: 60.0,
|
||||
files: vec![
|
||||
FileCoverage { path: "a.rs".to_string(), coverage: 10.0 },
|
||||
FileCoverage { path: "b.rs".to_string(), coverage: 80.0 },
|
||||
FileCoverage {
|
||||
path: "a.rs".to_string(),
|
||||
coverage: 10.0,
|
||||
},
|
||||
FileCoverage {
|
||||
path: "b.rs".to_string(),
|
||||
coverage: 80.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
let result = format_coverage_report(&report);
|
||||
assert!(result.contains("66.2"), "should show overall: {result}");
|
||||
assert!(result.contains("60.0"), "should show threshold: {result}");
|
||||
assert!(result.contains("a.rs"), "should show lowest file: {result}");
|
||||
assert!(result.contains("10.0"), "should show lowest file pct: {result}");
|
||||
assert!(
|
||||
result.contains("10.0"),
|
||||
"should show lowest file pct: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -490,9 +535,18 @@ Frontend line coverage: 70.0%\n\
|
||||
PASS: Coverage 66.25% meets threshold 60.00%\n\
|
||||
";
|
||||
let result = parse_coverage_output(sample, true);
|
||||
assert!(result.contains("62.5"), "should include Rust coverage: {result}");
|
||||
assert!(result.contains("70.0"), "should include Frontend coverage: {result}");
|
||||
assert!(result.contains("66.25"), "should include Overall coverage: {result}");
|
||||
assert!(
|
||||
result.contains("62.5"),
|
||||
"should include Rust coverage: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("70.0"),
|
||||
"should include Frontend coverage: {result}"
|
||||
);
|
||||
assert!(
|
||||
result.contains("66.25"),
|
||||
"should include Overall coverage: {result}"
|
||||
);
|
||||
assert!(result.contains("PASS"), "should indicate PASS: {result}");
|
||||
}
|
||||
|
||||
|
||||
@@ -128,14 +128,20 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("depends"), "help should list depends command: {output}");
|
||||
assert!(
|
||||
output.contains("depends"),
|
||||
"help should list depends command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn depends_no_args_returns_usage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = depends_cmd_with_root(tmp.path(), "").unwrap();
|
||||
assert!(output.contains("Usage"), "no args should show usage: {output}");
|
||||
assert!(
|
||||
output.contains("Usage"),
|
||||
"no args should show usage: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -188,10 +194,9 @@ mod tests {
|
||||
output.contains("477") && output.contains("478"),
|
||||
"response should mention dep numbers: {output}"
|
||||
);
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let contents =
|
||||
std::fs::read_to_string(tmp.path().join(".huskies/work/1_backlog/42_story_foo.md"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
contents.contains("depends_on: [477, 478]"),
|
||||
"file should have depends_on set: {contents}"
|
||||
@@ -212,10 +217,9 @@ mod tests {
|
||||
output.contains("Cleared"),
|
||||
"should confirm clearing deps: {output}"
|
||||
);
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/10_story_bar.md"),
|
||||
)
|
||||
.unwrap();
|
||||
let contents =
|
||||
std::fs::read_to_string(tmp.path().join(".huskies/work/2_current/10_story_bar.md"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!contents.contains("depends_on"),
|
||||
"file should have depends_on cleared: {contents}"
|
||||
|
||||
@@ -100,9 +100,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn git_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("git"), "help should list git command: {output}");
|
||||
assert!(
|
||||
output.contains("git"),
|
||||
"help should list git command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -197,7 +204,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn git_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy GIT");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy GIT",
|
||||
);
|
||||
assert!(result.is_some(), "GIT should match case-insensitively");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Handler for the `help` command.
|
||||
|
||||
use super::{commands, CommandContext};
|
||||
use super::{CommandContext, commands};
|
||||
|
||||
pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||
let mut output = format!("**{} Commands**\n\n", ctx.bot_name);
|
||||
@@ -14,7 +14,7 @@ pub(super) fn handle_help(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::tests::{try_cmd_addressed, commands};
|
||||
use super::super::tests::{commands, try_cmd_addressed};
|
||||
|
||||
#[test]
|
||||
fn help_command_matches() {
|
||||
@@ -74,7 +74,10 @@ mod tests {
|
||||
fn help_output_includes_status() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("status"), "help should list status command: {output}");
|
||||
assert!(
|
||||
output.contains("status"),
|
||||
"help should list status command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -86,7 +89,9 @@ mod tests {
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let marker = format!("**{}**", c.name);
|
||||
let pos = output.find(&marker).expect("command must appear in help as **name**");
|
||||
let pos = output
|
||||
.find(&marker)
|
||||
.expect("command must appear in help as **name**");
|
||||
(pos, c.name)
|
||||
})
|
||||
.collect();
|
||||
@@ -94,20 +99,29 @@ mod tests {
|
||||
let names_in_order: Vec<&str> = positions.iter().map(|(_, n)| *n).collect();
|
||||
let mut sorted = names_in_order.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names_in_order, sorted, "commands must appear in alphabetical order");
|
||||
assert_eq!(
|
||||
names_in_order, sorted,
|
||||
"commands must appear in alphabetical order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_ambient() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("ambient"), "help should list ambient command: {output}");
|
||||
assert!(
|
||||
output.contains("ambient"),
|
||||
"help should list ambient command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_output_includes_htop() {
|
||||
let result = try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("htop"), "help should list htop command: {output}");
|
||||
assert!(
|
||||
output.contains("htop"),
|
||||
"help should list htop command: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,53 @@ fn loc_top_n(project_root: &std::path::Path, top_n: usize) -> String {
|
||||
fn is_source_extension(ext: &str) -> bool {
|
||||
matches!(
|
||||
ext,
|
||||
"rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go" | "java" | "c" | "cpp" | "h"
|
||||
| "hpp" | "cs" | "rb" | "swift" | "kt" | "scala" | "hs" | "ml" | "ex" | "exs"
|
||||
| "clj" | "lua" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "toml" | "yaml"
|
||||
| "yml" | "json" | "md" | "html" | "css" | "scss" | "less" | "sql" | "graphql"
|
||||
| "proto" | "tf" | "hcl" | "nix" | "r" | "jl" | "dart" | "vue" | "svelte"
|
||||
"rs" | "ts"
|
||||
| "tsx"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "py"
|
||||
| "go"
|
||||
| "java"
|
||||
| "c"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "rb"
|
||||
| "swift"
|
||||
| "kt"
|
||||
| "scala"
|
||||
| "hs"
|
||||
| "ml"
|
||||
| "ex"
|
||||
| "exs"
|
||||
| "clj"
|
||||
| "lua"
|
||||
| "sh"
|
||||
| "bash"
|
||||
| "zsh"
|
||||
| "fish"
|
||||
| "ps1"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "json"
|
||||
| "md"
|
||||
| "html"
|
||||
| "css"
|
||||
| "scss"
|
||||
| "less"
|
||||
| "sql"
|
||||
| "graphql"
|
||||
| "proto"
|
||||
| "tf"
|
||||
| "hcl"
|
||||
| "nix"
|
||||
| "r"
|
||||
| "jl"
|
||||
| "dart"
|
||||
| "vue"
|
||||
| "svelte"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -202,7 +244,10 @@ mod tests {
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("loc"), "help should list loc command: {output}");
|
||||
assert!(
|
||||
output.contains("loc"),
|
||||
"help should list loc command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,7 +265,10 @@ mod tests {
|
||||
);
|
||||
// At most 10 entries (numbered lines "1." through "10.")
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 10, "default should return at most 10 files, got {count}");
|
||||
assert!(
|
||||
count <= 10,
|
||||
"default should return at most 10 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,7 +281,10 @@ mod tests {
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "5");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 5, "loc 5 should return at most 5 files, got {count}");
|
||||
assert!(
|
||||
count <= 5,
|
||||
"loc 5 should return at most 5 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -246,7 +297,10 @@ mod tests {
|
||||
let ctx = make_ctx(&agents, &ambient_rooms, repo_root, "20");
|
||||
let output = handle_loc(&ctx).unwrap();
|
||||
let count = output.lines().filter(|l| l.contains(". `")).count();
|
||||
assert!(count <= 20, "loc 20 should return at most 20 files, got {count}");
|
||||
assert!(
|
||||
count <= 20,
|
||||
"loc 20 should return at most 20 files, got {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -110,7 +110,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
if file_num == num_str
|
||||
&& let Some(c) = crate::db::read_content(&id)
|
||||
{
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
@@ -119,7 +121,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
let stages = [
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in &stages {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
|
||||
@@ -86,9 +86,7 @@ pub(super) fn handle_test(ctx: &CommandContext) -> Option<String> {
|
||||
let mut result = format!("**Test: {status}**\n\n");
|
||||
|
||||
if tests_passed > 0 || tests_failed > 0 {
|
||||
result.push_str(&format!(
|
||||
"{tests_passed} passed, {tests_failed} failed\n\n"
|
||||
));
|
||||
result.push_str(&format!("{tests_passed} passed, {tests_failed} failed\n\n"));
|
||||
}
|
||||
|
||||
result.push_str(&format!("```\n{truncated}\n```"));
|
||||
@@ -128,7 +126,11 @@ fn parse_test_counts(output: &str) -> (u64, u64) {
|
||||
fn extract_count(line: &str, label: &str) -> Option<u64> {
|
||||
let pos = line.find(label)?;
|
||||
let before = line[..pos].trim_end();
|
||||
let num_str: String = before.chars().rev().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let num_str: String = before
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect();
|
||||
if num_str.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -250,10 +252,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_command_works_via_dispatch() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write_script(
|
||||
dir.path(),
|
||||
"#!/usr/bin/env bash\necho 'ok'\nexit 0\n",
|
||||
);
|
||||
write_script(dir.path(), "#!/usr/bin/env bash\necho 'ok'\nexit 0\n");
|
||||
let agents = test_agents();
|
||||
let ambient = test_ambient();
|
||||
let room_id = "!test:example.com".to_string();
|
||||
@@ -317,8 +316,14 @@ mod tests {
|
||||
let ambient = test_ambient();
|
||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "");
|
||||
let output = handle_test(&ctx).unwrap();
|
||||
assert!(output.contains("PASS"), "no-arg should use project root: {output}");
|
||||
assert!(output.contains('7'), "should show count from project root script: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"no-arg should use project root: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains('7'),
|
||||
"should show count from project root script: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -329,8 +334,14 @@ mod tests {
|
||||
let ambient = test_ambient();
|
||||
let ctx = make_ctx(&agents, &ambient, dir.path(), "541");
|
||||
let output = handle_test(&ctx).unwrap();
|
||||
assert!(output.contains("PASS"), "should run tests in worktree: {output}");
|
||||
assert!(output.contains('2'), "should show count from worktree script: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"should run tests in worktree: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains('2'),
|
||||
"should show count from worktree script: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -382,6 +393,9 @@ mod tests {
|
||||
"run_tests with story number must respond via dispatch"
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("PASS"), "should PASS for valid worktree: {output}");
|
||||
assert!(
|
||||
output.contains("PASS"),
|
||||
"should PASS for valid worktree: {output}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ use super::CommandContext;
|
||||
use crate::http::mcp::wizard_tools::{
|
||||
generation_hint, is_script_step, step_output_path, write_if_missing,
|
||||
};
|
||||
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
||||
use crate::io::wizard::{StepStatus, WizardState, format_wizard_state};
|
||||
|
||||
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||
let sub = ctx.args.trim().to_ascii_lowercase();
|
||||
@@ -84,17 +84,16 @@ fn wizard_confirm_reply(ctx: &CommandContext) -> String {
|
||||
let content = state.steps[idx].content.clone();
|
||||
|
||||
// Write content to disk (only if a file path exists and the file is absent).
|
||||
let write_msg =
|
||||
if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||
let executable = is_script_step(step);
|
||||
match write_if_missing(path, c, executable) {
|
||||
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let write_msg = if let (Some(c), Some(ref path)) = (&content, step_output_path(root, step)) {
|
||||
let executable = is_script_step(step);
|
||||
match write_if_missing(path, c, executable) {
|
||||
Ok(true) => format!(" File written: `{}`.", path.display()),
|
||||
Ok(false) => format!(" File `{}` already exists — skipped.", path.display()),
|
||||
Err(e) => return format!("Error: {e}"),
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
if let Err(e) = state.confirm_step(step) {
|
||||
return format!("Cannot confirm step: {e}");
|
||||
@@ -140,10 +139,7 @@ fn wizard_skip_reply(ctx: &CommandContext) -> String {
|
||||
}
|
||||
|
||||
if state.completed {
|
||||
format!(
|
||||
"Step '{}' skipped. Setup wizard complete!",
|
||||
step.label()
|
||||
)
|
||||
format!("Step '{}' skipped. Setup wizard complete!", step.label())
|
||||
} else {
|
||||
let next = &state.steps[state.current_step_index()];
|
||||
format!(
|
||||
|
||||
@@ -78,9 +78,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn show_command_appears_in_help() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy help");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy help",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("show"), "help should list show command: {output}");
|
||||
assert!(
|
||||
output.contains("show"),
|
||||
"help should list show command: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -167,7 +174,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn show_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy SHOW 1");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy SHOW 1",
|
||||
);
|
||||
assert!(result.is_some(), "SHOW should match case-insensitively");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,14 +119,13 @@ fn build_status_from_items(
|
||||
.collect();
|
||||
|
||||
// Read token usage once for all stories to avoid repeated file I/O.
|
||||
let cost_by_story: HashMap<String, f64> =
|
||||
crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
let cost_by_story: HashMap<String, f64> = crate::agents::token_usage::read_all(project_root)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.fold(HashMap::new(), |mut map, r| {
|
||||
*map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd;
|
||||
map
|
||||
});
|
||||
|
||||
let config = ProjectConfig::load(project_root).ok();
|
||||
|
||||
@@ -165,10 +164,8 @@ fn build_status_from_items(
|
||||
}
|
||||
|
||||
// Blocked items: Archived { reason: Blocked } shown with 🔴 indicator.
|
||||
let mut blocked_items: Vec<&PipelineItem> = items
|
||||
.iter()
|
||||
.filter(|i| i.stage.is_blocked())
|
||||
.collect();
|
||||
let mut blocked_items: Vec<&PipelineItem> =
|
||||
items.iter().filter(|i| i.stage.is_blocked()).collect();
|
||||
blocked_items.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0));
|
||||
if !blocked_items.is_empty() {
|
||||
out.push_str(&format!("**Blocked** ({})\n", blocked_items.len()));
|
||||
@@ -294,13 +291,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_command_matches() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy status",
|
||||
);
|
||||
assert!(result.is_some(), "status command should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_command_returns_pipeline_text() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy status",
|
||||
);
|
||||
let output = result.unwrap();
|
||||
assert!(
|
||||
output.contains("Pipeline Status"),
|
||||
@@ -310,7 +315,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn status_command_case_insensitive() {
|
||||
let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS");
|
||||
let result = super::super::tests::try_cmd_addressed(
|
||||
"Timmy",
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy STATUS",
|
||||
);
|
||||
assert!(result.is_some(), "STATUS should match case-insensitively");
|
||||
}
|
||||
|
||||
@@ -318,7 +327,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_extracts_number_and_name() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert_eq!(label, "293 [story] — Register all bot commands");
|
||||
}
|
||||
|
||||
@@ -336,7 +348,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_does_not_include_underscore_slug() {
|
||||
let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands"));
|
||||
let label = story_short_label(
|
||||
"293_story_register_all_bot_commands_in_the_command_registry",
|
||||
Some("Register all bot commands"),
|
||||
);
|
||||
assert!(
|
||||
!label.contains("story_register"),
|
||||
"label should not contain the slug portion: {label}"
|
||||
@@ -345,19 +360,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_bug_type() {
|
||||
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
|
||||
let label = story_short_label(
|
||||
"375_bug_default_project_toml",
|
||||
Some("Default project.toml issue"),
|
||||
);
|
||||
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_spike_type() {
|
||||
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
|
||||
let label = story_short_label(
|
||||
"61_spike_filesystem_watcher_architecture",
|
||||
Some("Filesystem watcher architecture"),
|
||||
);
|
||||
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_label_shows_refactor_type() {
|
||||
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
|
||||
let label = story_short_label(
|
||||
"260_refactor_upgrade_libsqlite3_sys",
|
||||
Some("Upgrade libsqlite3-sys"),
|
||||
);
|
||||
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
||||
}
|
||||
|
||||
@@ -506,7 +530,12 @@ mod tests {
|
||||
// Story 10 depends on story 999, which is NOT in all_items (treated as met)
|
||||
// OR present in backlog (unmet). Let's add dep 999 in Backlog stage (unmet).
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Coding, vec![999]),
|
||||
make_item_with_deps(
|
||||
"10_story_waiting",
|
||||
"Waiting Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Backlog),
|
||||
];
|
||||
|
||||
@@ -526,11 +555,20 @@ mod tests {
|
||||
|
||||
// Dep 999 is in Done stage — met.
|
||||
let items = vec![
|
||||
make_item_with_deps("10_story_unblocked", "Unblocked Story", Stage::Coding, vec![999]),
|
||||
make_item("999_story_dep", "Dep Story", Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
}),
|
||||
make_item_with_deps(
|
||||
"10_story_unblocked",
|
||||
"Unblocked Story",
|
||||
Stage::Coding,
|
||||
vec![999],
|
||||
),
|
||||
make_item(
|
||||
"999_story_dep",
|
||||
"Dep Story",
|
||||
Stage::Done {
|
||||
merged_at: Utc::now(),
|
||||
merge_commit: crate::pipeline_state::GitSha("abc123".to_string()),
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
let agents = AgentPool::new_test(3000);
|
||||
@@ -678,8 +716,12 @@ mod tests {
|
||||
|
||||
// Must appear under Done, not Backlog.
|
||||
let done_pos = output.find("**Done**").expect("Done section must exist");
|
||||
let backlog_pos = output.find("**Backlog**").expect("Backlog section must exist");
|
||||
let story_pos = output.find("503 [story]").expect("story must appear in output");
|
||||
let backlog_pos = output
|
||||
.find("**Backlog**")
|
||||
.expect("Backlog section must exist");
|
||||
let story_pos = output
|
||||
.find("503 [story]")
|
||||
.expect("story must appear in output");
|
||||
|
||||
assert!(
|
||||
story_pos > done_pos,
|
||||
|
||||
@@ -33,17 +33,13 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
match find_story_by_number(num_str) {
|
||||
Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)),
|
||||
None => Some(format!(
|
||||
"Story **{num_str}** not found in the pipeline."
|
||||
)),
|
||||
None => Some(format!("Story **{num_str}** not found in the pipeline.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a pipeline item whose numeric prefix matches `num_str` by querying the
|
||||
/// CRDT state. Returns `(story_id, PipelineItem)` for the first match.
|
||||
fn find_story_by_number(
|
||||
num_str: &str,
|
||||
) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||
fn find_story_by_number(num_str: &str) -> Option<(String, crate::pipeline_state::PipelineItem)> {
|
||||
let items = crate::pipeline_state::read_all_typed();
|
||||
for item in items {
|
||||
let file_num = item
|
||||
@@ -74,7 +70,10 @@ fn build_triage_dump(
|
||||
};
|
||||
|
||||
let meta = crate::io::story_metadata::parse_front_matter(&contents).ok();
|
||||
let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)");
|
||||
let name = meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.name.as_deref())
|
||||
.unwrap_or("(unnamed)");
|
||||
|
||||
let mut out = String::new();
|
||||
|
||||
@@ -147,10 +146,7 @@ fn build_triage_dump(
|
||||
out.push_str(&format!("**Branch:** `{branch}`\n\n"));
|
||||
|
||||
// ---- git diff --stat ----
|
||||
let diff_stat = run_git(
|
||||
&wt_path,
|
||||
&["diff", "--stat", "master...HEAD"],
|
||||
);
|
||||
let diff_stat = run_git(&wt_path, &["diff", "--stat", "master...HEAD"]);
|
||||
if !diff_stat.is_empty() {
|
||||
out.push_str("**Diff stat (vs master):**\n```\n");
|
||||
out.push_str(&diff_stat);
|
||||
@@ -162,12 +158,7 @@ fn build_triage_dump(
|
||||
// ---- Last 5 commits on feature branch ----
|
||||
let log = run_git(
|
||||
&wt_path,
|
||||
&[
|
||||
"log",
|
||||
"master..HEAD",
|
||||
"--pretty=format:%h %s",
|
||||
"-5",
|
||||
],
|
||||
&["log", "master..HEAD", "--pretty=format:%h %s", "-5"],
|
||||
);
|
||||
if !log.is_empty() {
|
||||
out.push_str("**Recent commits (branch only):**\n```\n");
|
||||
@@ -192,10 +183,15 @@ fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> {
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) {
|
||||
if let Some(text) = trimmed
|
||||
.strip_prefix("- [x] ")
|
||||
.or_else(|| trimmed.strip_prefix("- [X] "))
|
||||
{
|
||||
Some((true, text.to_string()))
|
||||
} else {
|
||||
trimmed.strip_prefix("- [ ] ").map(|text| (false, text.to_string()))
|
||||
trimmed
|
||||
.strip_prefix("- [ ] ")
|
||||
.map(|text| (false, text.to_string()))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
@@ -248,7 +244,10 @@ mod tests {
|
||||
#[test]
|
||||
fn whatsup_command_is_not_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "whatsup");
|
||||
assert!(!found, "whatsup command must not be in the registry (renamed to status)");
|
||||
assert!(
|
||||
!found,
|
||||
"whatsup command must not be in the registry (renamed to status)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -340,7 +339,10 @@ mod tests {
|
||||
"---\nname: Backlog Item\n---\n",
|
||||
);
|
||||
let output = status_triage_cmd(tmp.path(), "9901").unwrap();
|
||||
assert!(output.contains("9901"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("9901"),
|
||||
"should show story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Backlog Item"),
|
||||
"should show story name: {output}"
|
||||
@@ -361,7 +363,10 @@ mod tests {
|
||||
"---\nname: QA Item\n---\n",
|
||||
);
|
||||
let output = status_triage_cmd(tmp.path(), "9902").unwrap();
|
||||
assert!(output.contains("9902"), "should show story number: {output}");
|
||||
assert!(
|
||||
output.contains("9902"),
|
||||
"should show story number: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("QA Item"),
|
||||
"should show story name: {output}"
|
||||
@@ -439,7 +444,10 @@ mod tests {
|
||||
output.contains("depends_on") || output.contains("#477"),
|
||||
"should show depends_on field: {output}"
|
||||
);
|
||||
assert!(output.contains("478"), "should list all dependency numbers: {output}");
|
||||
assert!(
|
||||
output.contains("478"),
|
||||
"should list all dependency numbers: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -459,7 +467,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// -- parse_acceptance_criteria -----------------------------------------
|
||||
|
||||
#[test]
|
||||
@@ -479,5 +486,4 @@ mod tests {
|
||||
let result = parse_acceptance_criteria(input);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
//! and returns a confirmation.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
|
||||
use crate::io::story_metadata::{
|
||||
clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter,
|
||||
set_front_matter_field,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
/// Handle the `unblock` command.
|
||||
@@ -37,9 +40,7 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -71,9 +72,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
||||
let has_merge_failure = meta.merge_failure.is_some();
|
||||
|
||||
if !has_blocked && !has_merge_failure {
|
||||
return format!(
|
||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||
);
|
||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||
}
|
||||
|
||||
let mut updated = contents;
|
||||
@@ -94,9 +93,16 @@ fn unblock_by_story_id(story_id: &str) -> String {
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
let mut cleared = Vec::new();
|
||||
if has_blocked { cleared.push("blocked"); }
|
||||
if has_merge_failure { cleared.push("merge_failure"); }
|
||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||
if has_blocked {
|
||||
cleared.push("blocked");
|
||||
}
|
||||
if has_merge_failure {
|
||||
cleared.push("merge_failure");
|
||||
}
|
||||
format!(
|
||||
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||
cleared.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
/// Core unblock logic: reset blocked state for a known story file path.
|
||||
@@ -121,9 +127,7 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
||||
let has_merge_failure = meta.merge_failure.is_some();
|
||||
|
||||
if !has_blocked && !has_merge_failure {
|
||||
return format!(
|
||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||
);
|
||||
return format!("**{story_name}** ({story_id}) is not blocked. Nothing to unblock.");
|
||||
}
|
||||
|
||||
// Clear the blocked flag if present.
|
||||
@@ -147,9 +151,16 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
||||
}
|
||||
|
||||
let mut cleared = Vec::new();
|
||||
if has_blocked { cleared.push("blocked"); }
|
||||
if has_merge_failure { cleared.push("merge_failure"); }
|
||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||
if has_blocked {
|
||||
cleared.push("blocked");
|
||||
}
|
||||
if has_merge_failure {
|
||||
cleared.push("merge_failure");
|
||||
}
|
||||
format!(
|
||||
"Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.",
|
||||
cleared.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,7 +287,8 @@ mod tests {
|
||||
let contents = crate::db::read_content("9903_story_stuck")
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||
tmp.path()
|
||||
.join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
|
||||
@@ -17,9 +17,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
if commits.is_empty() {
|
||||
let msg = match &tag {
|
||||
Some(t) => format!(
|
||||
"No unreleased stories since the last release tag **{t}**."
|
||||
),
|
||||
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||
};
|
||||
return Some(msg);
|
||||
@@ -36,9 +34,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
|
||||
if stories.is_empty() {
|
||||
let msg = match &tag {
|
||||
Some(t) => format!(
|
||||
"No unreleased stories since the last release tag **{t}**."
|
||||
),
|
||||
Some(t) => format!("No unreleased stories since the last release tag **{t}**."),
|
||||
None => "No release tags found and no story merge commits on master.".to_string(),
|
||||
};
|
||||
return Some(msg);
|
||||
@@ -50,8 +46,7 @@ pub(super) fn handle_unreleased(ctx: &CommandContext) -> Option<String> {
|
||||
None => "**Unreleased stories (no prior release tag):**\n\n".to_string(),
|
||||
};
|
||||
for (num, slug) in &stories {
|
||||
let name = find_story_name(root, &num.to_string())
|
||||
.unwrap_or_else(|| slug_to_name(slug));
|
||||
let name = find_story_name(root, &num.to_string()).unwrap_or_else(|| slug_to_name(slug));
|
||||
out.push_str(&format!("- **{num}** — {name}\n"));
|
||||
}
|
||||
Some(out)
|
||||
@@ -79,10 +74,7 @@ fn find_last_release_tag(root: &std::path::Path) -> Option<String> {
|
||||
|
||||
/// Return the subjects of all `huskies: merge …` commits reachable from HEAD
|
||||
/// but not from `since_tag` (or all commits when `since_tag` is `None`).
|
||||
fn list_merge_commits_since(
|
||||
root: &std::path::Path,
|
||||
since_tag: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
fn list_merge_commits_since(root: &std::path::Path, since_tag: Option<&str>) -> Vec<String> {
|
||||
use std::process::Command;
|
||||
|
||||
let range = match since_tag {
|
||||
@@ -153,7 +145,9 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
if file_num == num_str
|
||||
&& let Some(c) = crate::db::read_content(&id)
|
||||
{
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
@@ -162,7 +156,12 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
];
|
||||
for stage in STAGES {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
@@ -225,7 +224,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn unreleased_command_is_registered() {
|
||||
let found = super::super::commands().iter().any(|c| c.name == "unreleased");
|
||||
let found = super::super::commands()
|
||||
.iter()
|
||||
.any(|c| c.name == "unreleased");
|
||||
assert!(found, "unreleased command must be in the registry");
|
||||
}
|
||||
|
||||
@@ -249,7 +250,10 @@ mod tests {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let output = unreleased_cmd_with_root(tmp.path()).unwrap();
|
||||
// Should return some message (not panic), either about no tags or no commits.
|
||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
||||
assert!(
|
||||
!output.is_empty(),
|
||||
"should return a non-empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -261,7 +265,10 @@ mod tests {
|
||||
let output = unreleased_cmd_with_root(repo_root).unwrap();
|
||||
// The response should mention "unreleased" or "no unreleased" — just make
|
||||
// sure it's non-empty and doesn't panic.
|
||||
assert!(!output.is_empty(), "should return a non-empty message: {output}");
|
||||
assert!(
|
||||
!output.is_empty(),
|
||||
"should return a non-empty message: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -271,7 +278,10 @@ mod tests {
|
||||
"@timmy:homeserver.local",
|
||||
"@timmy UNRELEASED",
|
||||
);
|
||||
assert!(result.is_some(), "UNRELEASED should match case-insensitively");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"UNRELEASED should match case-insensitively"
|
||||
);
|
||||
}
|
||||
|
||||
// -- parse_story_from_subject ------------------------------------------
|
||||
|
||||
@@ -80,7 +80,10 @@ mod tests {
|
||||
fn not_found_returns_none() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let result = find_story_by_number(tmp.path(), "999");
|
||||
assert!(result.is_none(), "should return None when story is not found");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"should return None when story is not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
|
||||
pub mod commands;
|
||||
pub(crate) mod lookup;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
pub mod timer;
|
||||
pub mod transport;
|
||||
pub mod util;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@@ -96,8 +96,9 @@ mod tests {
|
||||
fn assert_transport<T: ChatTransport>() {}
|
||||
assert_transport::<crate::chat::transport::slack::SlackTransport>();
|
||||
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()));
|
||||
let _: Arc<dyn ChatTransport> = Arc::new(
|
||||
crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
|
||||
@@ -107,11 +108,12 @@ mod tests {
|
||||
fn assert_transport<T: ChatTransport>() {}
|
||||
assert_transport::<crate::chat::transport::whatsapp::TwilioWhatsAppTransport>();
|
||||
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||
let _: Arc<dyn ChatTransport> = Arc::new(
|
||||
crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||
"ACtest".to_string(),
|
||||
"authtoken".to_string(),
|
||||
"+14155551234".to_string(),
|
||||
));
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+48
-67
@@ -161,10 +161,7 @@ pub(crate) async fn tick_once(
|
||||
}
|
||||
|
||||
let remaining = store.list().len();
|
||||
crate::slog!(
|
||||
"[timer] Tick: {} due, {remaining} remaining",
|
||||
due.len()
|
||||
);
|
||||
crate::slog!("[timer] Tick: {} due, {remaining} remaining", due.len());
|
||||
|
||||
for entry in due {
|
||||
crate::slog!("[timer] Timer fired for story {}", entry.story_id);
|
||||
@@ -287,9 +284,7 @@ pub fn spawn_rate_limit_auto_scheduler(
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
|
||||
crate::slog!(
|
||||
"[timer] Rate-limit auto-scheduler lagged, skipped {n} events"
|
||||
);
|
||||
crate::slog!("[timer] Rate-limit auto-scheduler lagged, skipped {n} events");
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
crate::slog!(
|
||||
@@ -398,44 +393,43 @@ pub async fn handle_timer_command(
|
||||
let story_id = match resolve_story_id(&story_number_or_id, project_root) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return format!(
|
||||
"No story with number or ID **{story_number_or_id}** found."
|
||||
);
|
||||
return format!("No story with number or ID **{story_number_or_id}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
// The story must be in backlog or current. When the timer fires,
|
||||
// backlog stories are moved to current automatically.
|
||||
// Check CRDT state first, then fall back to filesystem.
|
||||
let in_valid_stage = if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
|
||||
use crate::pipeline_state::Stage;
|
||||
matches!(item.stage, Stage::Backlog | Stage::Coding)
|
||||
} else {
|
||||
let work_dir = project_root.join(".huskies").join("work");
|
||||
work_dir.join("1_backlog").join(format!("{story_id}.md")).exists()
|
||||
|| work_dir.join("2_current").join(format!("{story_id}.md")).exists()
|
||||
};
|
||||
let in_valid_stage =
|
||||
if let Ok(Some(item)) = crate::pipeline_state::read_typed(&story_id) {
|
||||
use crate::pipeline_state::Stage;
|
||||
matches!(item.stage, Stage::Backlog | Stage::Coding)
|
||||
} else {
|
||||
let work_dir = project_root.join(".huskies").join("work");
|
||||
work_dir
|
||||
.join("1_backlog")
|
||||
.join(format!("{story_id}.md"))
|
||||
.exists()
|
||||
|| work_dir
|
||||
.join("2_current")
|
||||
.join(format!("{story_id}.md"))
|
||||
.exists()
|
||||
};
|
||||
if !in_valid_stage {
|
||||
return format!(
|
||||
"Story **{story_id}** is not in backlog or current."
|
||||
);
|
||||
return format!("Story **{story_id}** is not in backlog or current.");
|
||||
}
|
||||
|
||||
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return format!(
|
||||
"Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`)."
|
||||
);
|
||||
return format!("Invalid time **{hhmm}**. Use `HH:MM` format (e.g. `14:30`).");
|
||||
}
|
||||
};
|
||||
|
||||
match store.add(story_id.clone(), scheduled_at) {
|
||||
Ok(()) => {
|
||||
let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
|
||||
format!(
|
||||
"Timer set for **{story_id}** at **{display_time}** ({tz_label})."
|
||||
)
|
||||
format!("Timer set for **{story_id}** at **{display_time}** ({tz_label}).")
|
||||
}
|
||||
Err(e) => format!("Failed to save timer: {e}"),
|
||||
}
|
||||
@@ -448,11 +442,7 @@ pub async fn handle_timer_command(
|
||||
let mut lines = vec!["**Pending timers:**".to_string()];
|
||||
for t in &timers {
|
||||
let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str);
|
||||
lines.push(format!(
|
||||
"- **{}** → {}",
|
||||
t.story_id,
|
||||
display_time
|
||||
));
|
||||
lines.push(format!("- **{}** → {}", t.story_id, display_time));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
@@ -465,13 +455,11 @@ pub async fn handle_timer_command(
|
||||
format!("No timer found for **{story_id}**.")
|
||||
}
|
||||
}
|
||||
TimerCommand::BadArgs => {
|
||||
"Usage:\n\
|
||||
TimerCommand::BadArgs => "Usage:\n\
|
||||
- `timer <story_id> <HH:MM>` — schedule deferred start\n\
|
||||
- `timer list` — show pending timers\n\
|
||||
- `timer cancel <story_id>` — remove a timer"
|
||||
.to_string()
|
||||
}
|
||||
.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,10 +517,7 @@ fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, Str
|
||||
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
|
||||
Some(tz) => {
|
||||
let tz_time = dt.with_timezone(&tz);
|
||||
(
|
||||
tz_time.format("%Y-%m-%d %H:%M").to_string(),
|
||||
tz.to_string(),
|
||||
)
|
||||
(tz_time.format("%Y-%m-%d %H:%M").to_string(), tz.to_string())
|
||||
}
|
||||
None => {
|
||||
let local_time = dt.with_timezone(&Local);
|
||||
@@ -571,7 +556,12 @@ fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == number_or_id && crate::pipeline_state::read_typed(&id).ok().flatten().is_some() {
|
||||
if file_num == number_or_id
|
||||
&& crate::pipeline_state::read_typed(&id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
@@ -643,14 +633,20 @@ mod tests {
|
||||
#[test]
|
||||
fn next_occurrence_with_named_timezone_is_in_the_future() {
|
||||
let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap();
|
||||
assert!(result > Utc::now(), "next occurrence (Europe/London) must be in the future");
|
||||
assert!(
|
||||
result > Utc::now(),
|
||||
"next occurrence (Europe/London) must be in the future"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_occurrence_with_invalid_timezone_falls_back_to_local() {
|
||||
// An unrecognised timezone name falls back to chrono::Local (returns Some).
|
||||
let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone"));
|
||||
assert!(result.is_some(), "invalid timezone should fall back to local and return Some");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"invalid timezone should fall back to local and return Some"
|
||||
);
|
||||
}
|
||||
|
||||
// ── extract_timer_command ───────────────────────────────────────────
|
||||
@@ -679,11 +675,7 @@ mod tests {
|
||||
#[test]
|
||||
fn timer_cancel_story_id() {
|
||||
assert_eq!(
|
||||
extract_timer_command(
|
||||
"Timmy timer cancel 421_story_foo",
|
||||
"Timmy",
|
||||
"@bot:home"
|
||||
),
|
||||
extract_timer_command("Timmy timer cancel 421_story_foo", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::Cancel {
|
||||
story_number_or_id: "421_story_foo".to_string()
|
||||
})
|
||||
@@ -701,11 +693,7 @@ mod tests {
|
||||
#[test]
|
||||
fn timer_schedule_with_story_id() {
|
||||
assert_eq!(
|
||||
extract_timer_command(
|
||||
"Timmy timer 421_story_foo 14:30",
|
||||
"Timmy",
|
||||
"@bot:home"
|
||||
),
|
||||
extract_timer_command("Timmy timer 421_story_foo 14:30", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::Schedule {
|
||||
story_number_or_id: "421_story_foo".to_string(),
|
||||
hhmm: "14:30".to_string(),
|
||||
@@ -727,11 +715,7 @@ mod tests {
|
||||
#[test]
|
||||
fn timer_schedule_missing_time_is_bad_args() {
|
||||
assert_eq!(
|
||||
extract_timer_command(
|
||||
"Timmy timer 421_story_foo",
|
||||
"Timmy",
|
||||
"@bot:home"
|
||||
),
|
||||
extract_timer_command("Timmy timer 421_story_foo", "Timmy", "@bot:home"),
|
||||
Some(TimerCommand::BadArgs)
|
||||
);
|
||||
}
|
||||
@@ -944,10 +928,7 @@ mod tests {
|
||||
dir.path(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.contains("No timer found"),
|
||||
"unexpected: {result}"
|
||||
);
|
||||
assert!(result.contains("No timer found"), "unexpected: {result}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1014,10 +995,7 @@ mod tests {
|
||||
dir.path(),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
result.contains("Timer set for"),
|
||||
"unexpected: {result}"
|
||||
);
|
||||
assert!(result.contains("Timer set for"), "unexpected: {result}");
|
||||
assert_eq!(store.list().len(), 1);
|
||||
}
|
||||
|
||||
@@ -1111,7 +1089,10 @@ mod tests {
|
||||
"story should be in the content store after timer fires"
|
||||
);
|
||||
// Timer was consumed.
|
||||
assert!(store.list().is_empty(), "fired timer should be removed from store");
|
||||
assert!(
|
||||
store.list().is_empty(),
|
||||
"fired timer should be removed from store"
|
||||
);
|
||||
}
|
||||
|
||||
// ── AC4: tick_once integration test ─────────────────────────────────
|
||||
|
||||
@@ -6,9 +6,9 @@ use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
||||
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::chat::util::is_permission_approval;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use crate::slog;
|
||||
|
||||
@@ -42,8 +42,7 @@ pub struct DiscordContext {
|
||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
/// Pending permission replies keyed by channel ID.
|
||||
pub pending_perm_replies:
|
||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||
pub permission_timeout_secs: u64,
|
||||
}
|
||||
@@ -135,16 +134,13 @@ pub(super) async fn handle_incoming_message(
|
||||
let total_ticks = (duration_secs as usize) / 2;
|
||||
for tick in 1..=total_ticks {
|
||||
tokio::time::sleep(interval).await;
|
||||
let updated =
|
||||
crate::chat::transport::matrix::htop::build_htop_message(
|
||||
&agents,
|
||||
(tick * 2) as u32,
|
||||
duration_secs,
|
||||
);
|
||||
let updated = crate::chat::transport::matrix::htop::build_htop_message(
|
||||
&agents,
|
||||
(tick * 2) as u32,
|
||||
duration_secs,
|
||||
);
|
||||
let updated = markdown_to_discord(&updated);
|
||||
if let Err(e) =
|
||||
transport.edit_message(&ch, &msg_id, &updated, "").await
|
||||
{
|
||||
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
|
||||
slog!("[discord] Failed to edit htop message: {e}");
|
||||
break;
|
||||
}
|
||||
@@ -320,12 +316,7 @@ pub(super) async fn handle_incoming_message(
|
||||
}
|
||||
|
||||
/// Forward a message to Claude Code and send the response back via Discord.
|
||||
async fn handle_llm_message(
|
||||
ctx: &DiscordContext,
|
||||
channel: &str,
|
||||
user: &str,
|
||||
user_message: &str,
|
||||
) {
|
||||
async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, user_message: &str) {
|
||||
use crate::chat::util::drain_complete_paragraphs;
|
||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -334,9 +325,7 @@ async fn handle_llm_message(
|
||||
// Look up existing session ID for this channel.
|
||||
let resume_session_id: Option<String> = {
|
||||
let guard = ctx.history.lock().await;
|
||||
guard
|
||||
.get(channel)
|
||||
.and_then(|conv| conv.session_id.clone())
|
||||
guard.get(channel).and_then(|conv| conv.session_id.clone())
|
||||
};
|
||||
|
||||
let bot_name = &ctx.bot_name;
|
||||
@@ -446,9 +435,7 @@ async fn handle_llm_message(
|
||||
let last_text = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
|
||||
})
|
||||
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
if !last_text.is_empty() {
|
||||
|
||||
@@ -150,8 +150,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
||||
.ok_or("Gateway closed before Hello")?
|
||||
.map_err(|e| format!("Gateway read error: {e}"))?;
|
||||
|
||||
let hello_payload: GatewayPayload =
|
||||
parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
|
||||
let hello_payload: GatewayPayload = parse_ws_message(&hello).ok_or("Failed to parse Hello")?;
|
||||
|
||||
if hello_payload.op != OP_HELLO {
|
||||
return Err(format!(
|
||||
@@ -164,8 +163,7 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
||||
serde_json::from_value(hello_payload.d.ok_or("Hello missing data")?)
|
||||
.map_err(|e| format!("Failed to parse Hello data: {e}"))?;
|
||||
|
||||
let heartbeat_interval =
|
||||
std::time::Duration::from_millis(hello_data.heartbeat_interval);
|
||||
let heartbeat_interval = std::time::Duration::from_millis(hello_data.heartbeat_interval);
|
||||
slog!(
|
||||
"[discord] Heartbeat interval: {}ms",
|
||||
hello_data.heartbeat_interval
|
||||
@@ -258,19 +256,12 @@ async fn run_gateway(ctx: Arc<DiscordContext>) -> Result<(), String> {
|
||||
&& let Ok(ready) = serde_json::from_value::<ReadyData>(d)
|
||||
{
|
||||
bot_user_id = Some(ready.user.id.clone());
|
||||
slog!(
|
||||
"[discord] READY — bot user ID: {}",
|
||||
ready.user.id
|
||||
);
|
||||
slog!("[discord] READY — bot user ID: {}", ready.user.id);
|
||||
}
|
||||
}
|
||||
"MESSAGE_CREATE" => {
|
||||
if let Some(d) = payload.d {
|
||||
dispatch_message(
|
||||
Arc::clone(&ctx),
|
||||
d,
|
||||
bot_user_id.clone(),
|
||||
);
|
||||
dispatch_message(Arc::clone(&ctx), d, bot_user_id.clone());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -355,15 +346,11 @@ fn dispatch_message(
|
||||
|
||||
// Check if the bot was mentioned, or if we respond to all messages in
|
||||
// configured channels (ambient mode).
|
||||
let bot_mentioned = bot_user_id.as_ref().is_some_and(|bid| {
|
||||
msg.mentions.iter().any(|m| m.id == *bid)
|
||||
});
|
||||
let bot_mentioned = bot_user_id
|
||||
.as_ref()
|
||||
.is_some_and(|bid| msg.mentions.iter().any(|m| m.id == *bid));
|
||||
|
||||
let in_ambient = ctx
|
||||
.ambient_rooms
|
||||
.lock()
|
||||
.unwrap()
|
||||
.contains(&msg.channel_id);
|
||||
let in_ambient = ctx.ambient_rooms.lock().unwrap().contains(&msg.channel_id);
|
||||
|
||||
if !bot_mentioned && !in_ambient {
|
||||
return;
|
||||
@@ -392,8 +379,7 @@ fn dispatch_message(
|
||||
msg.channel_id
|
||||
);
|
||||
|
||||
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content)
|
||||
.await;
|
||||
commands::handle_incoming_message(&ctx, &msg.channel_id, &author.id, &content).await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -417,8 +403,7 @@ mod tests {
|
||||
let json = r#"{"op": 10, "d": {"heartbeat_interval": 41250}}"#;
|
||||
let payload: GatewayPayload = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(payload.op, OP_HELLO);
|
||||
let hello: HelloData =
|
||||
serde_json::from_value(payload.d.unwrap()).unwrap();
|
||||
let hello: HelloData = serde_json::from_value(payload.d.unwrap()).unwrap();
|
||||
assert_eq!(hello.heartbeat_interval, 41250);
|
||||
}
|
||||
|
||||
|
||||
@@ -181,8 +181,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.send_message("123456", "hello", "<p>hello</p>")
|
||||
@@ -202,8 +201,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
let result = transport.send_message("bad", "hello", "").await;
|
||||
assert!(result.is_err());
|
||||
@@ -220,8 +218,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.edit_message("123456", "999888777", "updated", "")
|
||||
@@ -240,12 +237,9 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.edit_message("123456", "bad", "updated", "")
|
||||
.await;
|
||||
let result = transport.edit_message("123456", "bad", "updated", "").await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("404"));
|
||||
}
|
||||
@@ -259,8 +253,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
assert!(transport.send_typing("123456", true).await.is_ok());
|
||||
}
|
||||
@@ -281,8 +274,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport =
|
||||
DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
let transport = DiscordTransport::with_api_base("test-token".to_string(), server.url());
|
||||
|
||||
let result = transport.send_message("123456", "hello", "").await;
|
||||
assert!(result.is_err());
|
||||
@@ -296,7 +288,6 @@ mod tests {
|
||||
fn assert_transport<T: ChatTransport>() {}
|
||||
assert_transport::<DiscordTransport>();
|
||||
|
||||
let _: Arc<dyn ChatTransport> =
|
||||
Arc::new(DiscordTransport::new("test-token".to_string()));
|
||||
let _: Arc<dyn ChatTransport> = Arc::new(DiscordTransport::new("test-token".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,7 @@ use std::path::Path;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum AssignCommand {
|
||||
/// Assign the story with this number to the given model.
|
||||
Assign {
|
||||
story_number: String,
|
||||
model: String,
|
||||
},
|
||||
Assign { story_number: String, model: String },
|
||||
/// The user typed `assign` but without valid arguments.
|
||||
BadArgs,
|
||||
}
|
||||
@@ -96,9 +93,7 @@ pub async fn handle_assign(
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -282,11 +277,8 @@ mod tests {
|
||||
fn extract_assign_command_multibyte_prefix_no_panic() {
|
||||
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
|
||||
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
|
||||
let cmd = extract_assign_command(
|
||||
"xxxx\u{23FA} assign 42 opus",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
let cmd =
|
||||
extract_assign_command("xxxx\u{23FA} assign 42 opus", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, None);
|
||||
}
|
||||
|
||||
@@ -453,7 +445,8 @@ mod tests {
|
||||
);
|
||||
// Should indicate a restart occurred (not just "will be used when starts")
|
||||
assert!(
|
||||
response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"),
|
||||
response.to_lowercase().contains("stop")
|
||||
|| response.to_lowercase().contains("reassign"),
|
||||
"response should indicate stop/reassign: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::timer::TimerStore;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::chat::timer::TimerStore;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
@@ -104,7 +104,10 @@ mod tests {
|
||||
#[test]
|
||||
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
|
||||
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
|
||||
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online.");
|
||||
assert_eq!(
|
||||
format_startup_announcement("Assistant"),
|
||||
"Assistant is online."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -71,11 +71,7 @@ pub fn load_history(project_root: &std::path::Path) -> HashMap<OwnedRoomId, Room
|
||||
persisted
|
||||
.rooms
|
||||
.into_iter()
|
||||
.filter_map(|(k, v)| {
|
||||
k.parse::<OwnedRoomId>()
|
||||
.ok()
|
||||
.map(|room_id| (room_id, v))
|
||||
})
|
||||
.filter_map(|(k, v)| k.parse::<OwnedRoomId>().ok().map(|room_id| (room_id, v)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -97,9 +97,7 @@ pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &s
|
||||
// Handles both "@localpart" and "@localpart:homeserver" forms.
|
||||
if let Some(rest) = lower.strip_prefix('@') {
|
||||
// Extract everything up to the first whitespace character.
|
||||
let word_end = rest
|
||||
.find(|c: char| c.is_whitespace())
|
||||
.unwrap_or(rest.len());
|
||||
let word_end = rest.find(|c: char| c.is_whitespace()).unwrap_or(rest.len());
|
||||
let mention = &rest[..word_end]; // e.g. "sally" or "sally:example.com"
|
||||
|
||||
// Strip the homeserver part to get just the localpart.
|
||||
|
||||
@@ -82,9 +82,7 @@ pub(super) async fn on_room_message(
|
||||
// Always let "ambient on" through — it is the one command that must work
|
||||
// even when the bot is not mentioned and ambient mode is off, otherwise
|
||||
// there is no way to re-enable ambient mode without an @-mention.
|
||||
let is_ambient_on = body
|
||||
.to_ascii_lowercase()
|
||||
.contains("ambient on");
|
||||
let is_ambient_on = body.to_ascii_lowercase().contains("ambient on");
|
||||
|
||||
if !is_addressed && !is_ambient && !is_ambient_on {
|
||||
slog!(
|
||||
@@ -97,7 +95,9 @@ pub(super) async fn on_room_message(
|
||||
// In ambient mode, ignore messages that are explicitly addressed to a
|
||||
// different entity (e.g. "sally: do X" or "@sally do X" when we are stu).
|
||||
// We still let through messages addressed to us and the "ambient on" command.
|
||||
if is_ambient && !is_addressed && !is_ambient_on
|
||||
if is_ambient
|
||||
&& !is_addressed
|
||||
&& !is_ambient_on
|
||||
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
|
||||
{
|
||||
slog!(
|
||||
@@ -158,7 +158,10 @@ pub(super) async fn on_room_message(
|
||||
"Permission denied."
|
||||
};
|
||||
let html = markdown_to_html(confirmation);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, confirmation, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, confirmation, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -182,9 +185,14 @@ pub(super) async fn on_room_message(
|
||||
ambient_rooms: &ctx.ambient_rooms,
|
||||
room_id: &room_id_str,
|
||||
};
|
||||
if let Some((response, response_html)) = super::super::commands::try_handle_command_with_html(&dispatch, &user_message) {
|
||||
if let Some((response, response_html)) =
|
||||
super::super::commands::try_handle_command_with_html(&dispatch, &user_message)
|
||||
{
|
||||
slog!("[matrix-bot] Handled bot command from {sender}");
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &response_html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &response_html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -224,7 +232,10 @@ pub(super) async fn on_room_message(
|
||||
}
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -272,9 +283,7 @@ pub(super) async fn on_room_message(
|
||||
) {
|
||||
let response = match del_cmd {
|
||||
super::super::delete::DeleteCommand::Delete { story_number } => {
|
||||
slog!(
|
||||
"[matrix-bot] Handling delete command from {sender}: story {story_number}"
|
||||
);
|
||||
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
|
||||
super::super::delete::handle_delete(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -288,7 +297,10 @@ pub(super) async fn on_room_message(
|
||||
}
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -305,9 +317,7 @@ pub(super) async fn on_room_message(
|
||||
) {
|
||||
let response = match rmtree_cmd {
|
||||
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||
slog!(
|
||||
"[matrix-bot] Handling rmtree command from {sender}: story {story_number}"
|
||||
);
|
||||
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
|
||||
super::super::rmtree::handle_rmtree(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -321,7 +331,10 @@ pub(super) async fn on_room_message(
|
||||
}
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -361,7 +374,10 @@ pub(super) async fn on_room_message(
|
||||
}
|
||||
};
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -387,7 +403,10 @@ pub(super) async fn on_room_message(
|
||||
)
|
||||
.await;
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -408,19 +427,22 @@ pub(super) async fn on_room_message(
|
||||
// Acknowledge immediately — the rebuild may take a while or re-exec.
|
||||
let ack = "Rebuilding server… this may take a moment.";
|
||||
let ack_html = markdown_to_html(ack);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, ack, &ack_html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, ack, &ack_html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
let response = super::super::rebuild::handle_rebuild(
|
||||
&ctx.bot_name,
|
||||
&ctx.project_root,
|
||||
&ctx.agents,
|
||||
)
|
||||
.await;
|
||||
let response =
|
||||
super::super::rebuild::handle_rebuild(&ctx.bot_name, &ctx.project_root, &ctx.agents)
|
||||
.await;
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -443,7 +465,10 @@ pub(super) async fn on_room_message(
|
||||
)
|
||||
.await;
|
||||
let html = markdown_to_html(&response);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, &response, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
@@ -470,9 +495,7 @@ pub(super) async fn handle_message(
|
||||
// flattening history into a text prefix.
|
||||
let resume_session_id: Option<String> = {
|
||||
let guard = ctx.history.lock().await;
|
||||
guard
|
||||
.get(&room_id)
|
||||
.and_then(|conv| conv.session_id.clone())
|
||||
guard.get(&room_id).and_then(|conv| conv.session_id.clone())
|
||||
};
|
||||
|
||||
// The prompt is just the current message with sender attribution.
|
||||
@@ -501,7 +524,9 @@ pub(super) async fn handle_message(
|
||||
let post_task = tokio::spawn(async move {
|
||||
while let Some(chunk) = msg_rx.recv().await {
|
||||
let html = markdown_to_html(&chunk);
|
||||
if let Ok(msg_id) = post_transport.send_message(&post_room_id, &chunk, &html).await
|
||||
if let Ok(msg_id) = post_transport
|
||||
.send_message(&post_room_id, &chunk, &html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
sent_ids_for_post.lock().await.insert(event_id);
|
||||
@@ -631,9 +656,7 @@ pub(super) async fn handle_message(
|
||||
Err(e) => {
|
||||
slog!("[matrix-bot] LLM error: {e}");
|
||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||
format!(
|
||||
"Authentication required. [Click here to log in to Claude]({url})"
|
||||
)
|
||||
format!("Authentication required. [Click here to log in to Claude]({url})")
|
||||
} else {
|
||||
format!("Error processing your request: {e}")
|
||||
};
|
||||
@@ -654,7 +677,11 @@ pub(super) async fn handle_message(
|
||||
let conv = guard.entry(room_id).or_default();
|
||||
|
||||
// Store the session ID so the next turn uses --resume.
|
||||
slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id);
|
||||
slog!(
|
||||
"[matrix-bot] storing session_id: {:?} (was: {:?})",
|
||||
new_session_id,
|
||||
conv.session_id
|
||||
);
|
||||
if new_session_id.is_some() {
|
||||
conv.session_id = new_session_id;
|
||||
}
|
||||
@@ -713,7 +740,10 @@ mod tests {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||
let msg = format!("Authentication required. [Click here to log in to Claude]({})", url.unwrap());
|
||||
let msg = format!(
|
||||
"Authentication required. [Click here to log in to Claude]({})",
|
||||
url.unwrap()
|
||||
);
|
||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||
assert!(msg.contains("[Click here to log in to Claude]"));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Matrix bot run loop — connects to the homeserver and processes sync events.
|
||||
use crate::agents::AgentPool;
|
||||
use crate::slog;
|
||||
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
|
||||
use matrix_sdk::ruma::OwnedRoomId;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
|
||||
@@ -73,7 +73,10 @@ pub async fn run_bot(
|
||||
.ok_or_else(|| "No user ID after login".to_string())?
|
||||
.to_owned();
|
||||
|
||||
slog!("[matrix-bot] Logged in as {bot_user_id} (device: {})", login_response.device_id);
|
||||
slog!(
|
||||
"[matrix-bot] Logged in as {bot_user_id} (device: {})",
|
||||
login_response.device_id
|
||||
);
|
||||
|
||||
// Bootstrap cross-signing keys for E2EE verification support.
|
||||
// Pass the bot's password for UIA (User-Interactive Authentication) —
|
||||
@@ -81,9 +84,7 @@ pub async fn run_bot(
|
||||
{
|
||||
use matrix_sdk::ruma::api::client::uiaa;
|
||||
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
|
||||
uiaa::UserIdentifier::UserIdOrLocalpart(
|
||||
config.username.clone().unwrap_or_default(),
|
||||
),
|
||||
uiaa::UserIdentifier::UserIdOrLocalpart(config.username.clone().unwrap_or_default()),
|
||||
config.password.clone().unwrap_or_default(),
|
||||
));
|
||||
if let Err(e) = client
|
||||
@@ -171,11 +172,7 @@ pub async fn run_bot(
|
||||
);
|
||||
|
||||
// Restore persisted ambient rooms from config.
|
||||
let persisted_ambient: HashSet<String> = config
|
||||
.ambient_rooms
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let persisted_ambient: HashSet<String> = config.ambient_rooms.iter().cloned().collect();
|
||||
if !persisted_ambient.is_empty() {
|
||||
slog!(
|
||||
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
|
||||
@@ -189,11 +186,13 @@ pub async fn run_bot(
|
||||
"whatsapp" => {
|
||||
if config.whatsapp_provider == "twilio" {
|
||||
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
|
||||
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||
config.twilio_account_sid.clone().unwrap_or_default(),
|
||||
config.twilio_auth_token.clone().unwrap_or_default(),
|
||||
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||
))
|
||||
Arc::new(
|
||||
crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||
config.twilio_account_sid.clone().unwrap_or_default(),
|
||||
config.twilio_auth_token.clone().unwrap_or_default(),
|
||||
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
slog!("[matrix-bot] Using WhatsApp/Meta transport");
|
||||
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
|
||||
@@ -208,7 +207,9 @@ pub async fn run_bot(
|
||||
}
|
||||
_ => {
|
||||
slog!("[matrix-bot] Using Matrix transport");
|
||||
Arc::new(super::super::transport_impl::MatrixTransport::new(client.clone()))
|
||||
Arc::new(super::super::transport_impl::MatrixTransport::new(
|
||||
client.clone(),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,10 +223,7 @@ pub async fn run_bot(
|
||||
project_root.join(".huskies").join("timers.json"),
|
||||
));
|
||||
// Auto-schedule timers when an agent hits a hard rate limit.
|
||||
crate::chat::timer::spawn_rate_limit_auto_scheduler(
|
||||
Arc::clone(&timer_store),
|
||||
watcher_rx_auto,
|
||||
);
|
||||
crate::chat::timer::spawn_rate_limit_auto_scheduler(Arc::clone(&timer_store), watcher_rx_auto);
|
||||
|
||||
let ctx = BotContext {
|
||||
bot_user_id,
|
||||
@@ -246,7 +244,9 @@ pub async fn run_bot(
|
||||
timer_store,
|
||||
};
|
||||
|
||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||
slog!(
|
||||
"[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"
|
||||
);
|
||||
|
||||
// Register event handlers and inject shared context.
|
||||
client.add_event_handler_context(ctx);
|
||||
@@ -256,8 +256,7 @@ pub async fn run_bot(
|
||||
|
||||
// Spawn the stage-transition notification listener before entering the
|
||||
// sync loop so it starts receiving watcher events immediately.
|
||||
let notif_room_id_strings: Vec<String> =
|
||||
notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
let notif_room_id_strings: Vec<String> = notif_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
super::super::notifications::spawn_notification_listener(
|
||||
Arc::clone(&transport),
|
||||
move || notif_room_id_strings.clone(),
|
||||
@@ -269,8 +268,7 @@ pub async fn run_bot(
|
||||
// configured rooms when the server is about to stop (SIGINT/SIGTERM or rebuild).
|
||||
{
|
||||
let shutdown_transport = Arc::clone(&transport);
|
||||
let shutdown_rooms: Vec<String> =
|
||||
announce_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
let shutdown_rooms: Vec<String> = announce_room_ids.iter().map(|r| r.to_string()).collect();
|
||||
let shutdown_bot_name = announce_bot_name.clone();
|
||||
let mut rx = shutdown_rx;
|
||||
tokio::spawn(async move {
|
||||
@@ -400,8 +398,7 @@ mod tests {
|
||||
#[test]
|
||||
fn io_error_is_not_fatal() {
|
||||
let e: matrix_sdk::Error =
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused")
|
||||
.into();
|
||||
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused").into();
|
||||
assert!(!is_fatal_sync_error(&e));
|
||||
}
|
||||
|
||||
@@ -423,7 +420,11 @@ mod tests {
|
||||
const MAX_BACKOFF_SECS: u64 = 300;
|
||||
let steps: Vec<u64> = std::iter::successors(Some(5u64), |&d| {
|
||||
let next = (d * 2).min(MAX_BACKOFF_SECS);
|
||||
if next < MAX_BACKOFF_SECS { Some(next) } else { None }
|
||||
if next < MAX_BACKOFF_SECS {
|
||||
Some(next)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// First few steps: 5, 10, 20, 40, 80, 160
|
||||
@@ -433,4 +434,3 @@ mod tests {
|
||||
assert_eq!(steps[3], 40);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,8 +84,9 @@ pub(super) async fn on_to_device_verification_request(
|
||||
}
|
||||
break;
|
||||
}
|
||||
VerificationRequestState::Done
|
||||
| VerificationRequestState::Cancelled(_) => break,
|
||||
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -100,10 +101,7 @@ pub(super) async fn on_to_device_verification_request(
|
||||
/// Modern Element sends `m.key.verification.request` as an `m.room.message`
|
||||
/// event rather than a to-device event. We look for that message type and
|
||||
/// drive the same SAS flow as the to-device handler.
|
||||
pub(super) async fn on_room_verification_request(
|
||||
ev: OriginalSyncRoomMessageEvent,
|
||||
client: Client,
|
||||
) {
|
||||
pub(super) async fn on_room_verification_request(ev: OriginalSyncRoomMessageEvent, client: Client) {
|
||||
// Only act on in-room verification request messages.
|
||||
if !matches!(ev.content.msgtype, MessageType::VerificationRequest(_)) {
|
||||
return;
|
||||
@@ -152,8 +150,9 @@ pub(super) async fn on_room_verification_request(
|
||||
}
|
||||
break;
|
||||
}
|
||||
VerificationRequestState::Done
|
||||
| VerificationRequestState::Cancelled(_) => break,
|
||||
VerificationRequestState::Done | VerificationRequestState::Cancelled(_) => {
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +77,6 @@ pub struct BotConfig {
|
||||
|
||||
// ── WhatsApp Business API fields ─────────────────────────────────
|
||||
// These are only required when `transport = "whatsapp"`.
|
||||
|
||||
/// WhatsApp Business phone number ID from the Meta dashboard.
|
||||
#[serde(default)]
|
||||
pub whatsapp_phone_number_id: Option<String>,
|
||||
@@ -105,7 +104,6 @@ pub struct BotConfig {
|
||||
|
||||
// ── Twilio WhatsApp fields ─────────────────────────────────────────
|
||||
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
|
||||
|
||||
/// Twilio Account SID (starts with `AC`).
|
||||
#[serde(default)]
|
||||
pub twilio_account_sid: Option<String>,
|
||||
@@ -126,7 +124,6 @@ pub struct BotConfig {
|
||||
|
||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||
// These are only required when `transport = "slack"`.
|
||||
|
||||
/// Slack Bot User OAuth Token (starts with `xoxb-`).
|
||||
#[serde(default)]
|
||||
pub slack_bot_token: Option<String>,
|
||||
@@ -139,7 +136,6 @@ pub struct BotConfig {
|
||||
|
||||
// ── Discord Bot API fields ──────────────────────────────────────
|
||||
// These are only required when `transport = "discord"`.
|
||||
|
||||
/// Discord bot token from the Discord Developer Portal.
|
||||
#[serde(default)]
|
||||
pub discord_bot_token: Option<String>,
|
||||
@@ -189,21 +185,33 @@ impl BotConfig {
|
||||
if config.transport == "whatsapp" {
|
||||
if config.whatsapp_provider == "twilio" {
|
||||
// Validate Twilio-specific fields.
|
||||
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.twilio_account_sid
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_account_sid"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.twilio_auth_token
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_auth_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.twilio_whatsapp_number
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||
twilio_whatsapp_number"
|
||||
@@ -212,21 +220,33 @@ impl BotConfig {
|
||||
}
|
||||
} else {
|
||||
// Validate Meta (default) WhatsApp fields.
|
||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.whatsapp_phone_number_id
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_phone_number_id"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_access_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.whatsapp_access_token
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_access_token"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.whatsapp_verify_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.whatsapp_verify_token
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||
whatsapp_verify_token"
|
||||
@@ -243,7 +263,11 @@ impl BotConfig {
|
||||
);
|
||||
return None;
|
||||
}
|
||||
if config.slack_signing_secret.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.slack_signing_secret
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"slack\" requires \
|
||||
slack_signing_secret"
|
||||
@@ -259,7 +283,11 @@ impl BotConfig {
|
||||
}
|
||||
} else if config.transport == "discord" {
|
||||
// Validate Discord-specific fields.
|
||||
if config.discord_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
if config
|
||||
.discord_bot_token
|
||||
.as_ref()
|
||||
.is_none_or(|s| s.is_empty())
|
||||
{
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"discord\" requires \
|
||||
discord_bot_token"
|
||||
@@ -276,21 +304,15 @@ impl BotConfig {
|
||||
} else {
|
||||
// Default transport is Matrix — validate Matrix-specific fields.
|
||||
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"matrix\" requires homeserver"
|
||||
);
|
||||
eprintln!("[bot] bot.toml: transport=\"matrix\" requires homeserver");
|
||||
return None;
|
||||
}
|
||||
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"matrix\" requires username"
|
||||
);
|
||||
eprintln!("[bot] bot.toml: transport=\"matrix\" requires username");
|
||||
return None;
|
||||
}
|
||||
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
|
||||
eprintln!(
|
||||
"[bot] bot.toml: transport=\"matrix\" requires password"
|
||||
);
|
||||
eprintln!("[bot] bot.toml: transport=\"matrix\" requires password");
|
||||
return None;
|
||||
}
|
||||
if config.room_ids.is_empty() {
|
||||
@@ -402,7 +424,10 @@ enabled = true
|
||||
let result = BotConfig::load(tmp.path());
|
||||
assert!(result.is_some());
|
||||
let config = result.unwrap();
|
||||
assert_eq!(config.homeserver.as_deref(), Some("https://matrix.example.com"));
|
||||
assert_eq!(
|
||||
config.homeserver.as_deref(),
|
||||
Some("https://matrix.example.com")
|
||||
);
|
||||
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
|
||||
assert_eq!(
|
||||
config.effective_room_ids(),
|
||||
@@ -761,18 +786,9 @@ whatsapp_verify_token = "my-verify"
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "whatsapp");
|
||||
assert_eq!(
|
||||
config.whatsapp_phone_number_id.as_deref(),
|
||||
Some("123456")
|
||||
);
|
||||
assert_eq!(
|
||||
config.whatsapp_access_token.as_deref(),
|
||||
Some("EAAtoken")
|
||||
);
|
||||
assert_eq!(
|
||||
config.whatsapp_verify_token.as_deref(),
|
||||
Some("my-verify")
|
||||
);
|
||||
assert_eq!(config.whatsapp_phone_number_id.as_deref(), Some("123456"));
|
||||
assert_eq!(config.whatsapp_access_token.as_deref(), Some("EAAtoken"));
|
||||
assert_eq!(config.whatsapp_verify_token.as_deref(), Some("my-verify"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1106,14 +1122,8 @@ discord_channel_ids = ["123456789012345678"]
|
||||
.unwrap();
|
||||
let config = BotConfig::load(tmp.path()).unwrap();
|
||||
assert_eq!(config.transport, "discord");
|
||||
assert_eq!(
|
||||
config.discord_bot_token.as_deref(),
|
||||
Some("Bot.Token.Here")
|
||||
);
|
||||
assert_eq!(
|
||||
config.discord_channel_ids,
|
||||
vec!["123456789012345678"]
|
||||
);
|
||||
assert_eq!(config.discord_bot_token.as_deref(), Some("Bot.Token.Here"));
|
||||
assert_eq!(config.discord_channel_ids, vec!["123456789012345678"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1176,9 +1186,6 @@ discord_allowed_users = ["111222333", "444555666"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
config.discord_allowed_users,
|
||||
vec!["111222333", "444555666"]
|
||||
);
|
||||
assert_eq!(config.discord_allowed_users, vec!["111222333", "444555666"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,9 +65,7 @@ pub async fn handle_delete(
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ use std::time::Duration;
|
||||
use tokio::sync::{Mutex as TokioMutex, watch};
|
||||
|
||||
use crate::agents::{AgentPool, AgentStatus};
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::chat::util::strip_bot_mention;
|
||||
use crate::slog;
|
||||
use crate::chat::ChatTransport;
|
||||
|
||||
use super::bot::markdown_to_html;
|
||||
|
||||
@@ -51,7 +51,11 @@ pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
|
||||
/// - `htop stop` → `Stop`
|
||||
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
||||
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
||||
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
|
||||
pub fn extract_htop_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<HtopCommand> {
|
||||
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped.trim();
|
||||
|
||||
@@ -261,7 +265,10 @@ pub async fn run_htop_loop(
|
||||
let text = build_htop_message(&agents, tick as u32, duration_secs);
|
||||
let html = markdown_to_html(&text);
|
||||
|
||||
if let Err(e) = transport.edit_message(&room_id, &initial_message_id, &text, &html).await {
|
||||
if let Err(e) = transport
|
||||
.edit_message(&room_id, &initial_message_id, &text, &html)
|
||||
.await
|
||||
{
|
||||
slog!("[htop] Failed to update message: {e}");
|
||||
return;
|
||||
}
|
||||
@@ -274,7 +281,10 @@ pub async fn run_htop_loop(
|
||||
async fn send_stopped_message(transport: &dyn ChatTransport, room_id: &str, message_id: &str) {
|
||||
let text = "**htop** — monitoring stopped.";
|
||||
let html = markdown_to_html(text);
|
||||
if let Err(e) = transport.edit_message(room_id, message_id, text, &html).await {
|
||||
if let Err(e) = transport
|
||||
.edit_message(room_id, message_id, text, &html)
|
||||
.await
|
||||
{
|
||||
slog!("[htop] Failed to send stop message: {e}");
|
||||
}
|
||||
}
|
||||
@@ -302,7 +312,10 @@ pub async fn handle_htop_start(
|
||||
// Send the initial message.
|
||||
let initial_text = build_htop_message(&agents, 0, duration_secs);
|
||||
let initial_html = markdown_to_html(&initial_text);
|
||||
let message_id = match transport.send_message(room_id, &initial_text, &initial_html).await {
|
||||
let message_id = match transport
|
||||
.send_message(room_id, &initial_text, &initial_html)
|
||||
.await
|
||||
{
|
||||
Ok(id) => id,
|
||||
Err(e) => {
|
||||
slog!("[htop] Failed to send initial message: {e}");
|
||||
|
||||
@@ -21,11 +21,11 @@ pub mod commands;
|
||||
pub(crate) mod config;
|
||||
pub mod delete;
|
||||
pub mod htop;
|
||||
pub mod notifications;
|
||||
pub mod rebuild;
|
||||
pub mod reset;
|
||||
pub mod rmtree;
|
||||
pub mod start;
|
||||
pub mod notifications;
|
||||
pub mod transport_impl;
|
||||
|
||||
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
@@ -92,9 +92,16 @@ pub fn spawn_bot(
|
||||
let watcher_rx = watcher_tx.subscribe();
|
||||
let watcher_rx_auto = watcher_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) =
|
||||
bot::run_bot(config, root, watcher_rx, watcher_rx_auto, perm_rx, agents, shutdown_rx)
|
||||
.await
|
||||
if let Err(e) = bot::run_bot(
|
||||
config,
|
||||
root,
|
||||
watcher_rx,
|
||||
watcher_rx_auto,
|
||||
perm_rx,
|
||||
agents,
|
||||
shutdown_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
//! Subscribes to [`WatcherEvent`] broadcasts and posts a notification to all
|
||||
//! configured Matrix rooms whenever a work item moves between pipeline stages.
|
||||
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::config::ProjectConfig;
|
||||
use crate::io::story_metadata::parse_front_matter;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::slog;
|
||||
use crate::chat::ChatTransport;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
@@ -81,9 +81,7 @@ pub fn format_error_notification(
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!("\u{274c} #{number} {name} \u{2014} {reason}");
|
||||
let html = format!(
|
||||
"\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}"
|
||||
);
|
||||
let html = format!("\u{274c} <strong>#{number}</strong> <em>{name}</em> \u{2014} {reason}");
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
@@ -113,9 +111,8 @@ pub fn format_blocked_notification(
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}");
|
||||
let html = format!(
|
||||
"\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}"
|
||||
);
|
||||
let html =
|
||||
format!("\u{1f6ab} <strong>#{number}</strong> <em>{name}</em> \u{2014} BLOCKED: {reason}");
|
||||
(plain, html)
|
||||
}
|
||||
|
||||
@@ -126,7 +123,6 @@ const RATE_LIMIT_DEBOUNCE: Duration = Duration::from_secs(60);
|
||||
/// into a single notification (only the final stage is announced).
|
||||
const STAGE_TRANSITION_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
|
||||
/// Format a rate limit warning notification message.
|
||||
///
|
||||
/// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`.
|
||||
@@ -138,9 +134,8 @@ pub fn format_rate_limit_notification(
|
||||
let number = extract_story_number(item_id).unwrap_or(item_id);
|
||||
let name = story_name.unwrap_or(item_id);
|
||||
|
||||
let plain = format!(
|
||||
"\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit"
|
||||
);
|
||||
let plain =
|
||||
format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit");
|
||||
let html = format!(
|
||||
"\u{26a0}\u{fe0f} <strong>#{number}</strong> <em>{name}</em> \u{2014} \
|
||||
{agent_name} hit an API rate limit"
|
||||
@@ -223,9 +218,7 @@ pub fn spawn_notification_listener(
|
||||
// and must be skipped — the old inferred_from_stage fallback
|
||||
// produced wrong notifications for stories that skipped stages
|
||||
// (e.g. "QA → Merge" when QA was never entered).
|
||||
let from_display = from_stage
|
||||
.as_deref()
|
||||
.map(stage_display_name);
|
||||
let from_display = from_stage.as_deref().map(stage_display_name);
|
||||
let Some(from_display) = from_display else {
|
||||
continue; // creation or unknown transition — skip
|
||||
};
|
||||
@@ -246,33 +239,24 @@ pub fn spawn_notification_listener(
|
||||
e.2 = story_name.clone();
|
||||
}
|
||||
})
|
||||
.or_insert_with(|| {
|
||||
(from_display.to_string(), stage.clone(), story_name)
|
||||
});
|
||||
.or_insert_with(|| (from_display.to_string(), stage.clone(), story_name));
|
||||
|
||||
// Start or extend the debounce window.
|
||||
flush_deadline =
|
||||
Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||
flush_deadline = Some(tokio::time::Instant::now() + STAGE_TRANSITION_DEBOUNCE);
|
||||
}
|
||||
Ok(WatcherEvent::MergeFailure {
|
||||
ref story_id,
|
||||
ref reason,
|
||||
}) => {
|
||||
let story_name =
|
||||
read_story_name(&project_root, "4_merge", story_id);
|
||||
let (plain, html) = format_error_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
reason,
|
||||
);
|
||||
let story_name = read_story_name(&project_root, "4_merge", story_id);
|
||||
let (plain, html) =
|
||||
format_error_notification(story_id, story_name.as_deref(), reason);
|
||||
|
||||
slog!("[bot] Sending error notification: {plain}");
|
||||
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[bot] Failed to send error notification to {room_id}: {e}"
|
||||
);
|
||||
slog!("[bot] Failed to send error notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,11 +287,8 @@ pub fn spawn_notification_listener(
|
||||
rate_limit_last_notified.insert(debounce_key, now);
|
||||
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
let (plain, html) = format_rate_limit_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
agent_name,
|
||||
);
|
||||
let (plain, html) =
|
||||
format_rate_limit_notification(story_id, story_name.as_deref(), agent_name);
|
||||
|
||||
slog!("[bot] Sending rate-limit notification: {plain}");
|
||||
|
||||
@@ -325,19 +306,14 @@ pub fn spawn_notification_listener(
|
||||
ref reason,
|
||||
}) => {
|
||||
let story_name = find_story_name_any_stage(&project_root, story_id);
|
||||
let (plain, html) = format_blocked_notification(
|
||||
story_id,
|
||||
story_name.as_deref(),
|
||||
reason,
|
||||
);
|
||||
let (plain, html) =
|
||||
format_blocked_notification(story_id, story_name.as_deref(), reason);
|
||||
|
||||
slog!("[bot] Sending blocked notification: {plain}");
|
||||
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!(
|
||||
"[bot] Failed to send blocked notification to {room_id}: {e}"
|
||||
);
|
||||
slog!("[bot] Failed to send blocked notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,14 +338,10 @@ pub fn spawn_notification_listener(
|
||||
}
|
||||
Ok(_) => {} // Ignore other events
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
slog!(
|
||||
"[bot] Notification listener lagged, skipped {n} events"
|
||||
);
|
||||
slog!("[bot] Notification listener lagged, skipped {n} events");
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
slog!(
|
||||
"[bot] Watcher channel closed, stopping notification listener"
|
||||
);
|
||||
slog!("[bot] Watcher channel closed, stopping notification listener");
|
||||
// Flush any coalesced transitions that haven't fired yet.
|
||||
for (item_id, (from_display, to_stage_key, story_name)) in
|
||||
pending_transitions.drain()
|
||||
@@ -383,12 +355,8 @@ pub fn spawn_notification_listener(
|
||||
);
|
||||
slog!("[bot] Sending stage notification: {plain}");
|
||||
for room_id in &get_room_ids() {
|
||||
if let Err(e) =
|
||||
transport.send_message(room_id, &plain, &html).await
|
||||
{
|
||||
slog!(
|
||||
"[bot] Failed to send notification to {room_id}: {e}"
|
||||
);
|
||||
if let Err(e) = transport.send_message(room_id, &plain, &html).await {
|
||||
slog!("[bot] Failed to send notification to {room_id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -402,8 +370,8 @@ pub fn spawn_notification_listener(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use crate::chat::MessageId;
|
||||
use async_trait::async_trait;
|
||||
|
||||
// ── MockTransport ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -417,18 +385,38 @@ mod tests {
|
||||
impl MockTransport {
|
||||
fn new() -> (Arc<Self>, CallLog) {
|
||||
let calls: CallLog = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
(Arc::new(Self { calls: Arc::clone(&calls) }), calls)
|
||||
(
|
||||
Arc::new(Self {
|
||||
calls: Arc::clone(&calls),
|
||||
}),
|
||||
calls,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl crate::chat::ChatTransport for MockTransport {
|
||||
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
|
||||
async fn send_message(
|
||||
&self,
|
||||
room_id: &str,
|
||||
plain: &str,
|
||||
html: &str,
|
||||
) -> Result<MessageId, String> {
|
||||
self.calls.lock().unwrap().push((
|
||||
room_id.to_string(),
|
||||
plain.to_string(),
|
||||
html.to_string(),
|
||||
));
|
||||
Ok("mock-msg-id".to_string())
|
||||
}
|
||||
|
||||
async fn edit_message(&self, _room_id: &str, _id: &str, _plain: &str, _html: &str) -> Result<(), String> {
|
||||
async fn edit_message(
|
||||
&self,
|
||||
_room_id: &str,
|
||||
_id: &str,
|
||||
_plain: &str,
|
||||
_html: &str,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -462,10 +450,12 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "365_story_rate_limit".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "365_story_rate_limit".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Give the spawned task time to process the event.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
@@ -475,9 +465,15 @@ mod tests {
|
||||
let (room_id, plain, _html) = &calls[0];
|
||||
assert_eq!(room_id, "!room123:example.org");
|
||||
assert!(plain.contains("365"), "plain should contain story number");
|
||||
assert!(plain.contains("Rate Limit Test Story"), "plain should contain story name");
|
||||
assert!(
|
||||
plain.contains("Rate Limit Test Story"),
|
||||
"plain should contain story name"
|
||||
);
|
||||
assert!(plain.contains("coder-1"), "plain should contain agent name");
|
||||
assert!(plain.contains("rate limit"), "plain should mention rate limit");
|
||||
assert!(
|
||||
plain.contains("rate limit"),
|
||||
"plain should mention rate limit"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4: a second RateLimitWarning for the same agent within the debounce
|
||||
@@ -498,16 +494,22 @@ mod tests {
|
||||
|
||||
// Send the same warning twice in rapid succession.
|
||||
for _ in 0..2 {
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_debounce".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_debounce".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Debounce should suppress the second notification");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Debounce should suppress the second notification"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC4 (corollary): warnings for different agents are NOT debounced against
|
||||
@@ -526,19 +528,27 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_foo".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 2, "Different agents should each trigger a notification");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
2,
|
||||
"Different agents should each trigger a notification"
|
||||
);
|
||||
}
|
||||
|
||||
// ── dynamic room IDs (WhatsApp ambient_rooms pattern) ───────────────────
|
||||
@@ -573,25 +583,40 @@ mod tests {
|
||||
);
|
||||
|
||||
// Add a room after the listener is spawned (simulates a user messaging first).
|
||||
rooms.lock().unwrap().insert("phone:+15551234567".to_string());
|
||||
rooms
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("phone:+15551234567".to_string());
|
||||
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Wait longer than STAGE_TRANSITION_DEBOUNCE (200ms) so the coalesced
|
||||
// notification flushes.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Should deliver to the dynamically added room");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Should deliver to the dynamically added room"
|
||||
);
|
||||
assert_eq!(calls[0].0, "phone:+15551234567");
|
||||
assert!(calls[0].1.contains("10"), "plain should contain story number");
|
||||
assert!(calls[0].1.contains("Foo Story"), "plain should contain story name");
|
||||
assert!(
|
||||
calls[0].1.contains("10"),
|
||||
"plain should contain story number"
|
||||
);
|
||||
assert!(
|
||||
calls[0].1.contains("Foo Story"),
|
||||
"plain should contain story name"
|
||||
);
|
||||
}
|
||||
|
||||
/// When no rooms are registered (e.g. no WhatsApp users have messaged yet),
|
||||
@@ -603,20 +628,17 @@ mod tests {
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
Vec::new,
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
|
||||
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "3_qa".to_string(),
|
||||
item_id: "10_story_foo".to_string(),
|
||||
action: "qa".to_string(),
|
||||
commit_msg: "huskies: qa 10_story_foo".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -660,11 +682,7 @@ mod tests {
|
||||
#[test]
|
||||
fn read_story_name_reads_from_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp
|
||||
.path()
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("42_story_my_feature.md"),
|
||||
@@ -686,11 +704,7 @@ mod tests {
|
||||
#[test]
|
||||
fn read_story_name_returns_none_for_missing_name_field() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let stage_dir = tmp
|
||||
.path()
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
std::fs::create_dir_all(&stage_dir).unwrap();
|
||||
std::fs::write(
|
||||
stage_dir.join("42_story_no_name.md"),
|
||||
@@ -706,8 +720,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_with_story_name() {
|
||||
let (plain, html) =
|
||||
format_error_notification("262_story_bot_errors", Some("Bot error notifications"), "merge conflict in src/main.rs");
|
||||
let (plain, html) = format_error_notification(
|
||||
"262_story_bot_errors",
|
||||
Some("Bot error notifications"),
|
||||
"merge conflict in src/main.rs",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #262 Bot error notifications \u{2014} merge conflict in src/main.rs"
|
||||
@@ -720,12 +737,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_error_notification_without_story_name_falls_back_to_item_id() {
|
||||
let (plain, _html) =
|
||||
format_error_notification("42_bug_fix_thing", None, "tests failed");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{274c} #42 42_bug_fix_thing \u{2014} tests failed"
|
||||
);
|
||||
let (plain, _html) = format_error_notification("42_bug_fix_thing", None, "tests failed");
|
||||
assert_eq!(plain, "\u{274c} #42 42_bug_fix_thing \u{2014} tests failed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -759,8 +772,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_blocked_notification_falls_back_to_item_id() {
|
||||
let (plain, _html) =
|
||||
format_blocked_notification("42_story_thing", None, "empty diff");
|
||||
let (plain, _html) = format_blocked_notification("42_story_thing", None, "empty diff");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{1f6ab} #42 42_story_thing \u{2014} BLOCKED: empty diff"
|
||||
@@ -792,10 +804,12 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "425_story_blocking_test".to_string(),
|
||||
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "425_story_blocking_test".to_string(),
|
||||
reason: "Retry limit exceeded (3/3) at coder stage".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -804,10 +818,22 @@ mod tests {
|
||||
let (room_id, plain, html) = &calls[0];
|
||||
assert_eq!(room_id, "!room123:example.org");
|
||||
assert!(plain.contains("425"), "plain should contain story number");
|
||||
assert!(plain.contains("Blocking Test Story"), "plain should contain story name");
|
||||
assert!(plain.contains("BLOCKED"), "plain should contain BLOCKED label");
|
||||
assert!(plain.contains("Retry limit exceeded"), "plain should contain the reason");
|
||||
assert!(html.contains("BLOCKED"), "html should contain BLOCKED label");
|
||||
assert!(
|
||||
plain.contains("Blocking Test Story"),
|
||||
"plain should contain story name"
|
||||
);
|
||||
assert!(
|
||||
plain.contains("BLOCKED"),
|
||||
"plain should contain BLOCKED label"
|
||||
);
|
||||
assert!(
|
||||
plain.contains("Retry limit exceeded"),
|
||||
"plain should contain the reason"
|
||||
);
|
||||
assert!(
|
||||
html.contains("BLOCKED"),
|
||||
"html should contain BLOCKED label"
|
||||
);
|
||||
}
|
||||
|
||||
/// StoryBlocked with no room registered should not panic.
|
||||
@@ -818,17 +844,14 @@ mod tests {
|
||||
let (watcher_tx, watcher_rx) = broadcast::channel::<WatcherEvent>(16);
|
||||
let (transport, calls) = MockTransport::new();
|
||||
|
||||
spawn_notification_listener(
|
||||
transport,
|
||||
Vec::new,
|
||||
watcher_rx,
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
spawn_notification_listener(transport, Vec::new, watcher_rx, tmp.path().to_path_buf());
|
||||
|
||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "42_story_no_rooms".to_string(),
|
||||
reason: "empty diff".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "42_story_no_rooms".to_string(),
|
||||
reason: "empty diff".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -840,11 +863,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_rate_limit_notification_includes_agent_and_story() {
|
||||
let (plain, html) = format_rate_limit_notification(
|
||||
"365_story_my_feature",
|
||||
Some("My Feature"),
|
||||
"coder-2",
|
||||
);
|
||||
let (plain, html) =
|
||||
format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit"
|
||||
@@ -857,8 +877,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_rate_limit_notification_falls_back_to_item_id() {
|
||||
let (plain, _html) =
|
||||
format_rate_limit_notification("42_story_thing", None, "coder-1");
|
||||
let (plain, _html) = format_rate_limit_notification("42_story_thing", None, "coder-1");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{26a0}\u{fe0f} #42 42_story_thing \u{2014} coder-1 hit an API rate limit"
|
||||
@@ -869,12 +888,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_notification_done_stage_includes_party_emoji() {
|
||||
let (plain, html) = format_stage_notification(
|
||||
"353_story_done",
|
||||
Some("Done Story"),
|
||||
"Merge",
|
||||
"Done",
|
||||
);
|
||||
let (plain, html) =
|
||||
format_stage_notification("353_story_done", Some("Done Story"), "Merge", "Done");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"\u{1f389} #353 Done Story \u{2014} Merge \u{2192} Done"
|
||||
@@ -887,12 +902,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_notification_non_done_stage_has_no_emoji() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"42_story_thing",
|
||||
Some("Some Story"),
|
||||
"Backlog",
|
||||
"Current",
|
||||
);
|
||||
let (plain, _html) =
|
||||
format_stage_notification("42_story_thing", Some("Some Story"), "Backlog", "Current");
|
||||
assert!(!plain.contains("\u{1f389}"));
|
||||
}
|
||||
|
||||
@@ -916,26 +927,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn format_notification_without_story_name_falls_back_to_item_id() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"42_bug_fix_thing",
|
||||
None,
|
||||
"Current",
|
||||
"QA",
|
||||
);
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#42 42_bug_fix_thing \u{2014} Current \u{2192} QA"
|
||||
);
|
||||
let (plain, _html) = format_stage_notification("42_bug_fix_thing", None, "Current", "QA");
|
||||
assert_eq!(plain, "#42 42_bug_fix_thing \u{2014} Current \u{2192} QA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_notification_non_numeric_id_uses_full_id() {
|
||||
let (plain, _html) = format_stage_notification(
|
||||
"abc_story_thing",
|
||||
Some("Some Story"),
|
||||
"QA",
|
||||
"Merge",
|
||||
);
|
||||
let (plain, _html) =
|
||||
format_stage_notification("abc_story_thing", Some("Some Story"), "QA", "Merge");
|
||||
assert_eq!(
|
||||
plain,
|
||||
"#abc_story_thing Some Story \u{2014} QA \u{2192} Merge"
|
||||
@@ -967,15 +966,21 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_suppress".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_suppress".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 0, "RateLimitWarning should be suppressed when rate_limit_notifications = false");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
0,
|
||||
"RateLimitWarning should be suppressed when rate_limit_notifications = false"
|
||||
);
|
||||
}
|
||||
|
||||
/// RateLimitHardBlock is never posted to Matrix — it is logged server-side only.
|
||||
@@ -994,11 +999,13 @@ mod tests {
|
||||
);
|
||||
|
||||
let reset_at = chrono::Utc::now() + chrono::Duration::hours(1);
|
||||
watcher_tx.send(WatcherEvent::RateLimitHardBlock {
|
||||
story_id: "42_story_hard_block".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
reset_at,
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitHardBlock {
|
||||
story_id: "42_story_hard_block".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
reset_at,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -1028,10 +1035,12 @@ mod tests {
|
||||
tmp.path().to_path_buf(),
|
||||
);
|
||||
|
||||
watcher_tx.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "42_story_blocked".to_string(),
|
||||
reason: "retry limit exceeded".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::StoryBlocked {
|
||||
story_id: "42_story_blocked".to_string(),
|
||||
reason: "retry limit exceeded".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
@@ -1064,10 +1073,12 @@ mod tests {
|
||||
);
|
||||
|
||||
// First warning is sent.
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-1".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Disable notifications and trigger hot-reload.
|
||||
@@ -1080,14 +1091,20 @@ mod tests {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Second warning (different agent to bypass debounce) should be suppressed.
|
||||
watcher_tx.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::RateLimitWarning {
|
||||
story_id: "42_story_reload".to_string(),
|
||||
agent_name: "coder-2".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(calls.len(), 1, "Only the first warning should be sent; second should be suppressed after hot-reload");
|
||||
assert_eq!(
|
||||
calls.len(),
|
||||
1,
|
||||
"Only the first warning should be sent; second should be suppressed after hot-reload"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Bug 549: synthetic events with from_stage=None must not notify ──────
|
||||
@@ -1111,19 +1128,22 @@ mod tests {
|
||||
);
|
||||
|
||||
// Synthetic reassign event within 4_merge — no actual stage change.
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "reassign".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: None,
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "reassign".to_string(),
|
||||
commit_msg: String::new(),
|
||||
from_stage: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
let calls = calls.lock().unwrap();
|
||||
assert_eq!(
|
||||
calls.len(), 0,
|
||||
calls.len(),
|
||||
0,
|
||||
"Synthetic events with from_stage=None must not generate notifications"
|
||||
);
|
||||
}
|
||||
@@ -1152,13 +1172,15 @@ mod tests {
|
||||
);
|
||||
|
||||
// Story skips QA: from_stage is 2_current, not 3_qa.
|
||||
watcher_tx.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "merge".to_string(),
|
||||
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
}).unwrap();
|
||||
watcher_tx
|
||||
.send(WatcherEvent::WorkItem {
|
||||
stage: "4_merge".to_string(),
|
||||
item_id: "549_story_skip_qa".to_string(),
|
||||
action: "merge".to_string(),
|
||||
commit_msg: "huskies: merge 549_story_skip_qa".to_string(),
|
||||
from_stage: Some("2_current".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
|
||||
|
||||
@@ -73,11 +73,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd = extract_rebuild_command(
|
||||
"@timmy:home.local rebuild",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
let cmd =
|
||||
extract_rebuild_command("@timmy:home.local rebuild", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(RebuildCommand));
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ pub async fn handle_reset(
|
||||
) -> String {
|
||||
{
|
||||
let mut guard = history.lock().await;
|
||||
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
|
||||
let conv = guard
|
||||
.entry(room_id.clone())
|
||||
.or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
|
||||
@@ -75,8 +77,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd =
|
||||
extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
|
||||
let cmd = extract_reset_command("@timmy:home.local reset", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(cmd, Some(ResetCommand));
|
||||
}
|
||||
|
||||
@@ -115,21 +116,27 @@ mod tests {
|
||||
let room_id: OwnedRoomId = "!test:example.com".parse().unwrap();
|
||||
let history: ConversationHistory = Arc::new(TokioMutex::new({
|
||||
let mut m = HashMap::new();
|
||||
m.insert(room_id.clone(), RoomConversation {
|
||||
session_id: Some("old-session-id".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: "@alice:example.com".to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
});
|
||||
m.insert(
|
||||
room_id.clone(),
|
||||
RoomConversation {
|
||||
session_id: Some("old-session-id".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: "@alice:example.com".to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
m
|
||||
}));
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let response = handle_reset("Timmy", &room_id, &history, tmp.path()).await;
|
||||
|
||||
assert!(response.contains("reset"), "response should mention reset: {response}");
|
||||
assert!(
|
||||
response.contains("reset"),
|
||||
"response should mention reset: {response}"
|
||||
);
|
||||
|
||||
let guard = history.lock().await;
|
||||
let conv = guard.get(&room_id).unwrap();
|
||||
|
||||
@@ -107,9 +107,7 @@ pub async fn handle_rmtree(
|
||||
return format!("Failed to remove worktree for story {story_number}: {e}");
|
||||
}
|
||||
|
||||
crate::slog!(
|
||||
"[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})"
|
||||
);
|
||||
crate::slog!("[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})");
|
||||
|
||||
let mut response = format!("Removed worktree for **{story_id}**.");
|
||||
if !stopped_agents.is_empty() {
|
||||
@@ -131,11 +129,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_with_full_user_id() {
|
||||
let cmd = extract_rmtree_command(
|
||||
"@timmy:home.local rmtree 42",
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
let cmd =
|
||||
extract_rmtree_command("@timmy:home.local rmtree 42", "Timmy", "@timmy:home.local");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
Some(RmtreeCommand::Rmtree {
|
||||
|
||||
@@ -84,9 +84,7 @@ pub async fn handle_start(
|
||||
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
|
||||
Some(found) => found,
|
||||
None => {
|
||||
return format!(
|
||||
"No story, bug, or spike with number **{story_number}** found."
|
||||
);
|
||||
return format!("No story, bug, or spike with number **{story_number}** found.");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,7 +113,13 @@ pub async fn handle_start(
|
||||
);
|
||||
|
||||
match agents
|
||||
.start_agent(project_root, &story_id, resolved_agent.as_deref(), None, None)
|
||||
.start_agent(
|
||||
project_root,
|
||||
&story_id,
|
||||
resolved_agent.as_deref(),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(info) => {
|
||||
@@ -231,7 +235,14 @@ mod tests {
|
||||
async fn handle_start_returns_not_found_for_unknown_number() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project_root = tmp.path();
|
||||
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
|
||||
for stage in &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
] {
|
||||
std::fs::create_dir_all(project_root.join(".huskies").join("work").join(stage))
|
||||
.unwrap();
|
||||
}
|
||||
@@ -276,7 +287,8 @@ mod tests {
|
||||
"response must not say 'Failed' when coders are busy: {response}"
|
||||
);
|
||||
assert!(
|
||||
response.to_lowercase().contains("queue") || response.to_lowercase().contains("available"),
|
||||
response.to_lowercase().contains("queue")
|
||||
|| response.to_lowercase().contains("available"),
|
||||
"response must mention queued/available state: {response}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
//! Slack incoming message dispatch and slash command handling.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{Mutex as TokioMutex, oneshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::format::markdown_to_slack;
|
||||
use super::history::{SlackConversationHistory, save_slack_history};
|
||||
use super::meta::SlackTransport;
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::chat::util::is_permission_approval;
|
||||
use crate::slog;
|
||||
use crate::chat::ChatTransport;
|
||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||
use super::meta::SlackTransport;
|
||||
use super::history::{SlackConversationHistory, save_slack_history};
|
||||
use super::format::markdown_to_slack;
|
||||
use crate::slog;
|
||||
|
||||
// ── Slash command types ─────────────────────────────────────────────────
|
||||
|
||||
@@ -81,8 +81,7 @@ pub struct SlackWebhookContext {
|
||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
/// Pending permission replies keyed by channel ID.
|
||||
pub pending_perm_replies:
|
||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||
pub permission_timeout_secs: u64,
|
||||
}
|
||||
@@ -154,8 +153,11 @@ pub(super) async fn handle_incoming_message(
|
||||
}
|
||||
HtopCommand::Start { duration_secs } => {
|
||||
// On Slack, htop uses native message editing for live updates.
|
||||
let snapshot =
|
||||
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
||||
let snapshot = crate::chat::transport::matrix::htop::build_htop_message(
|
||||
&ctx.agents,
|
||||
0,
|
||||
duration_secs,
|
||||
);
|
||||
let snapshot = markdown_to_slack(&snapshot);
|
||||
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
||||
Ok(id) => id,
|
||||
@@ -179,9 +181,7 @@ pub(super) async fn handle_incoming_message(
|
||||
duration_secs,
|
||||
);
|
||||
let updated = markdown_to_slack(&updated);
|
||||
if let Err(e) =
|
||||
transport.edit_message(&ch, &msg_id, &updated, "").await
|
||||
{
|
||||
if let Err(e) = transport.edit_message(&ch, &msg_id, &updated, "").await {
|
||||
slog!("[slack] Failed to edit htop message: {e}");
|
||||
break;
|
||||
}
|
||||
@@ -245,7 +245,9 @@ pub(super) async fn handle_incoming_message(
|
||||
) {
|
||||
let response = match rmtree_cmd {
|
||||
crate::chat::transport::matrix::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||
slog!("[slack] Handling rmtree command from {user} in {channel}: story {story_number}");
|
||||
slog!(
|
||||
"[slack] Handling rmtree command from {user} in {channel}: story {story_number}"
|
||||
);
|
||||
crate::chat::transport::matrix::rmtree::handle_rmtree(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -273,7 +275,9 @@ pub(super) async fn handle_incoming_message(
|
||||
slog!("[slack] Handling reset command from {user} in {channel}");
|
||||
{
|
||||
let mut guard = ctx.history.lock().await;
|
||||
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
|
||||
let conv = guard
|
||||
.entry(channel.to_string())
|
||||
.or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
save_slack_history(&ctx.project_root, &guard);
|
||||
@@ -295,7 +299,9 @@ pub(super) async fn handle_incoming_message(
|
||||
story_number,
|
||||
agent_hint,
|
||||
} => {
|
||||
slog!("[slack] Handling start command from {user} in {channel}: story {story_number}");
|
||||
slog!(
|
||||
"[slack] Handling start command from {user} in {channel}: story {story_number}"
|
||||
);
|
||||
crate::chat::transport::matrix::start::handle_start(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -320,8 +326,13 @@ pub(super) async fn handle_incoming_message(
|
||||
&ctx.bot_user_id,
|
||||
) {
|
||||
let response = match assign_cmd {
|
||||
crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => {
|
||||
slog!("[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}");
|
||||
crate::chat::transport::matrix::assign::AssignCommand::Assign {
|
||||
story_number,
|
||||
model,
|
||||
} => {
|
||||
slog!(
|
||||
"[slack] Handling assign command from {user} in {channel}: story {story_number} model {model}"
|
||||
);
|
||||
crate::chat::transport::matrix::assign::handle_assign(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -352,17 +363,15 @@ async fn handle_llm_message(
|
||||
user: &str,
|
||||
user_message: &str,
|
||||
) {
|
||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||
use crate::chat::util::drain_complete_paragraphs;
|
||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::sync::watch;
|
||||
|
||||
// Look up existing session ID for this channel.
|
||||
let resume_session_id: Option<String> = {
|
||||
let guard = ctx.history.lock().await;
|
||||
guard
|
||||
.get(channel)
|
||||
.and_then(|conv| conv.session_id.clone())
|
||||
guard.get(channel).and_then(|conv| conv.session_id.clone())
|
||||
};
|
||||
|
||||
let bot_name = &ctx.bot_name;
|
||||
@@ -383,7 +392,9 @@ async fn handle_llm_message(
|
||||
let post_task = tokio::spawn(async move {
|
||||
while let Some(chunk) = msg_rx.recv().await {
|
||||
let formatted = markdown_to_slack(&chunk);
|
||||
let _ = post_transport.send_message(&post_channel, &formatted, "").await;
|
||||
let _ = post_transport
|
||||
.send_message(&post_channel, &formatted, "")
|
||||
.await;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -472,9 +483,7 @@ async fn handle_llm_message(
|
||||
let last_text = messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| {
|
||||
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
|
||||
})
|
||||
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||
.map(|m| m.content.clone())
|
||||
.unwrap_or_default();
|
||||
if !last_text.is_empty() {
|
||||
@@ -559,7 +568,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn slash_command_maps_status() {
|
||||
assert_eq!(slash_command_to_bot_keyword("/huskies-status"), Some("status"));
|
||||
assert_eq!(
|
||||
slash_command_to_bot_keyword("/huskies-status"),
|
||||
Some("status")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -600,9 +612,8 @@ mod tests {
|
||||
response_type: "ephemeral",
|
||||
text: "hello".to_string(),
|
||||
};
|
||||
let json: serde_json::Value = serde_json::from_str(
|
||||
&serde_json::to_string(&resp).unwrap()
|
||||
).unwrap();
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
|
||||
assert_eq!(json["response_type"], "ephemeral");
|
||||
assert_eq!(json["text"], "hello");
|
||||
}
|
||||
@@ -642,7 +653,10 @@ mod tests {
|
||||
};
|
||||
|
||||
let result = try_handle_command(&dispatch, &synthetic);
|
||||
assert!(result.is_some(), "status slash command should produce output via registry");
|
||||
assert!(
|
||||
result.is_some(),
|
||||
"status slash command should produce output via registry"
|
||||
);
|
||||
assert!(result.unwrap().contains("Pipeline Status"));
|
||||
}
|
||||
|
||||
@@ -671,7 +685,10 @@ mod tests {
|
||||
let result = try_handle_command(&dispatch, &synthetic);
|
||||
assert!(result.is_some(), "show slash command should produce output");
|
||||
let output = result.unwrap();
|
||||
assert!(output.contains("999"), "show output should reference the story number: {output}");
|
||||
assert!(
|
||||
output.contains("999"),
|
||||
"show output should reference the story number: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── rebuild command extraction ─────────────────────────────────────
|
||||
@@ -704,7 +721,10 @@ mod tests {
|
||||
"Huskies",
|
||||
"slack-bot",
|
||||
);
|
||||
assert!(result.is_none(), "'status' should not be recognised as rebuild");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"'status' should not be recognised as rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
// ── reset command extraction ───────────────────────────────────────
|
||||
@@ -731,21 +751,26 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_command_clears_slack_session() {
|
||||
use crate::chat::transport::matrix::{
|
||||
ConversationEntry, ConversationRole, RoomConversation,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
|
||||
let channel = "C01ABCDEF";
|
||||
let history: SlackConversationHistory = Arc::new(TokioMutex::new({
|
||||
let mut m = HashMap::new();
|
||||
m.insert(channel.to_string(), RoomConversation {
|
||||
session_id: Some("old-session".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: "U01GHIJKL".to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
});
|
||||
m.insert(
|
||||
channel.to_string(),
|
||||
RoomConversation {
|
||||
session_id: Some("old-session".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: "U01GHIJKL".to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
m
|
||||
}));
|
||||
|
||||
@@ -755,7 +780,9 @@ mod tests {
|
||||
|
||||
{
|
||||
let mut guard = history.lock().await;
|
||||
let conv = guard.entry(channel.to_string()).or_insert_with(RoomConversation::default);
|
||||
let conv = guard
|
||||
.entry(channel.to_string())
|
||||
.or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
save_slack_history(tmp.path(), &guard);
|
||||
@@ -862,6 +889,9 @@ mod tests {
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert!(result.is_none(), "'status' should not be recognised as assign on Slack");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"'status' should not be recognised as assign on Slack"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,8 @@ pub fn markdown_to_slack(text: &str) -> String {
|
||||
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
||||
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
||||
static RE_BOLD: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
static RE_STRIKETHROUGH: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||
static RE_LINK: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
||||
|
||||
@@ -105,8 +103,14 @@ mod tests {
|
||||
fn slack_fenced_code_block_preserved() {
|
||||
let input = "```rust\nlet x = 1;\n```";
|
||||
let output = markdown_to_slack(input);
|
||||
assert!(output.contains("let x = 1;"), "code block content must be preserved");
|
||||
assert!(output.contains("```"), "fenced code delimiters must be preserved");
|
||||
assert!(
|
||||
output.contains("let x = 1;"),
|
||||
"code block content must be preserved"
|
||||
);
|
||||
assert!(
|
||||
output.contains("```"),
|
||||
"fenced code delimiters must be preserved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -104,9 +104,8 @@ impl ChatTransport for SlackTransport {
|
||||
return Err(format!("Slack API returned {status}: {resp_text}"));
|
||||
}
|
||||
|
||||
let parsed: SlackApiResponse = serde_json::from_str(&resp_text).map_err(|e| {
|
||||
format!("Failed to parse Slack API response: {e} — body: {resp_text}")
|
||||
})?;
|
||||
let parsed: SlackApiResponse = serde_json::from_str(&resp_text)
|
||||
.map_err(|e| format!("Failed to parse Slack API response: {e} — body: {resp_text}"))?;
|
||||
|
||||
if !parsed.ok {
|
||||
return Err(format!(
|
||||
@@ -190,10 +189,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = SlackTransport::with_api_base(
|
||||
"xoxb-test-token".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.send_message("C01ABCDEF", "hello", "<p>hello</p>")
|
||||
@@ -212,14 +208,9 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = SlackTransport::with_api_base(
|
||||
"xoxb-test-token".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.send_message("C_INVALID", "hello", "")
|
||||
.await;
|
||||
let result = transport.send_message("C_INVALID", "hello", "").await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().contains("channel_not_found"),
|
||||
@@ -237,10 +228,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = SlackTransport::with_api_base(
|
||||
"xoxb-test-token".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.edit_message("C01ABCDEF", "1234567890.123456", "updated", "")
|
||||
@@ -258,10 +246,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = SlackTransport::with_api_base(
|
||||
"xoxb-test-token".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||
|
||||
let result = transport
|
||||
.edit_message("C01ABCDEF", "bad-ts", "updated", "")
|
||||
@@ -287,10 +272,7 @@ mod tests {
|
||||
.create_async()
|
||||
.await;
|
||||
|
||||
let transport = SlackTransport::with_api_base(
|
||||
"xoxb-test-token".to_string(),
|
||||
server.url(),
|
||||
);
|
||||
let transport = SlackTransport::with_api_base("xoxb-test-token".to_string(), server.url());
|
||||
|
||||
let result = transport.send_message("C01ABCDEF", "hello", "").await;
|
||||
assert!(result.is_err());
|
||||
|
||||
@@ -12,15 +12,15 @@ pub mod history;
|
||||
pub mod meta;
|
||||
pub mod verify;
|
||||
|
||||
pub use commands::SlackWebhookContext;
|
||||
pub use format::markdown_to_slack;
|
||||
pub use history::load_slack_history;
|
||||
pub use meta::SlackTransport;
|
||||
pub use format::markdown_to_slack;
|
||||
pub use commands::SlackWebhookContext;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use poem::{Request, Response, handler, http::StatusCode};
|
||||
use crate::slog;
|
||||
use poem::{Request, Response, handler, http::StatusCode};
|
||||
|
||||
// ── Slack Events API types ──────────────────────────────────────────────
|
||||
|
||||
@@ -71,10 +71,7 @@ pub async fn webhook_receive(
|
||||
.header("X-Slack-Request-Timestamp")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let signature = req
|
||||
.header("X-Slack-Signature")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
|
||||
|
||||
let bytes = match body.into_bytes().await {
|
||||
Ok(b) => b,
|
||||
@@ -98,9 +95,7 @@ pub async fn webhook_receive(
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
slog!("[slack] Failed to parse webhook payload: {e}");
|
||||
return Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body("ok");
|
||||
return Response::builder().status(StatusCode::OK).body("ok");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -124,8 +119,7 @@ pub async fn webhook_receive(
|
||||
&& event.r#type.as_deref() == Some("message")
|
||||
&& event.subtype.is_none()
|
||||
&& event.bot_id.is_none()
|
||||
&& let (Some(channel), Some(user), Some(text)) =
|
||||
(event.channel, event.user, event.text)
|
||||
&& let (Some(channel), Some(user), Some(text)) = (event.channel, event.user, event.text)
|
||||
&& ctx.channel_ids.contains(&channel)
|
||||
{
|
||||
let ctx = Arc::clone(*ctx);
|
||||
@@ -135,9 +129,7 @@ pub async fn webhook_receive(
|
||||
});
|
||||
}
|
||||
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body("ok")
|
||||
Response::builder().status(StatusCode::OK).body("ok")
|
||||
}
|
||||
|
||||
/// POST /webhook/slack/command — receive incoming Slack slash commands.
|
||||
@@ -155,10 +147,7 @@ pub async fn slash_command_receive(
|
||||
.header("X-Slack-Request-Timestamp")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let signature = req
|
||||
.header("X-Slack-Signature")
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let signature = req.header("X-Slack-Signature").unwrap_or("").to_string();
|
||||
|
||||
let bytes = match body.into_bytes().await {
|
||||
Ok(b) => b,
|
||||
@@ -178,16 +167,15 @@ pub async fn slash_command_receive(
|
||||
.body("Invalid signature");
|
||||
}
|
||||
|
||||
let payload: commands::SlackSlashCommandPayload =
|
||||
match serde_urlencoded::from_bytes(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
slog!("[slack] Failed to parse slash command payload: {e}");
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body("Bad request");
|
||||
}
|
||||
};
|
||||
let payload: commands::SlackSlashCommandPayload = match serde_urlencoded::from_bytes(&bytes) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
slog!("[slack] Failed to parse slash command payload: {e}");
|
||||
return Response::builder()
|
||||
.status(StatusCode::BAD_REQUEST)
|
||||
.body("Bad request");
|
||||
}
|
||||
};
|
||||
|
||||
slog!(
|
||||
"[slack] Slash command from {}: {} {}",
|
||||
|
||||
@@ -215,7 +215,12 @@ mod tests {
|
||||
let body = b"test body";
|
||||
|
||||
let sig = compute_test_signature("correct-secret", timestamp, body);
|
||||
assert!(!verify_slack_signature("wrong-secret", timestamp, body, &sig));
|
||||
assert!(!verify_slack_signature(
|
||||
"wrong-secret",
|
||||
timestamp,
|
||||
body,
|
||||
&sig
|
||||
));
|
||||
}
|
||||
|
||||
/// Helper to compute a test signature using our sha256 + HMAC implementation.
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
//! WhatsApp command handling — processes incoming WhatsApp messages as bot commands.
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::chat::util::is_permission_approval;
|
||||
use crate::http::context::{PermissionDecision};
|
||||
use crate::slog;
|
||||
use super::WhatsAppWebhookContext;
|
||||
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
||||
use super::history::save_whatsapp_history;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::chat::util::is_permission_approval;
|
||||
use crate::http::context::PermissionDecision;
|
||||
use crate::slog;
|
||||
|
||||
/// Dispatch an incoming WhatsApp message to bot commands.
|
||||
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
||||
pub(super) async fn handle_incoming_message(
|
||||
ctx: &WhatsAppWebhookContext,
|
||||
sender: &str,
|
||||
message: &str,
|
||||
) {
|
||||
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
||||
|
||||
// Allowlist check: when configured, silently ignore unauthorized senders.
|
||||
if !ctx.allowed_phones.is_empty()
|
||||
&& !ctx.allowed_phones.iter().any(|p| p == sender)
|
||||
{
|
||||
if !ctx.allowed_phones.is_empty() && !ctx.allowed_phones.iter().any(|p| p == sender) {
|
||||
slog!("[whatsapp] Ignoring message from unauthorized sender: {sender}");
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +175,9 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
|
||||
slog!("[whatsapp] Handling reset command from {sender}");
|
||||
{
|
||||
let mut guard = ctx.history.lock().await;
|
||||
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
|
||||
let conv = guard
|
||||
.entry(sender.to_string())
|
||||
.or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
save_whatsapp_history(&ctx.project_root, &guard);
|
||||
@@ -219,8 +223,13 @@ pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender
|
||||
&ctx.bot_user_id,
|
||||
) {
|
||||
let response = match assign_cmd {
|
||||
crate::chat::transport::matrix::assign::AssignCommand::Assign { story_number, model } => {
|
||||
slog!("[whatsapp] Handling assign command from {sender}: story {story_number} model {model}");
|
||||
crate::chat::transport::matrix::assign::AssignCommand::Assign {
|
||||
story_number,
|
||||
model,
|
||||
} => {
|
||||
slog!(
|
||||
"[whatsapp] Handling assign command from {sender}: story {story_number} model {model}"
|
||||
);
|
||||
crate::chat::transport::matrix::assign::handle_assign(
|
||||
&ctx.bot_name,
|
||||
&story_number,
|
||||
@@ -385,9 +394,7 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
||||
Err(e) => {
|
||||
slog!("[whatsapp] LLM error: {e}");
|
||||
let err_msg = if let Some(url) = crate::llm::oauth::extract_login_url_from_error(&e) {
|
||||
format!(
|
||||
"Authentication required. Log in to Claude here: {url}"
|
||||
)
|
||||
format!("Authentication required. Log in to Claude here: {url}")
|
||||
} else {
|
||||
format!("Error processing your request: {e}")
|
||||
};
|
||||
@@ -434,20 +441,18 @@ async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_mes
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::agents::AgentPool;
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
|
||||
use super::super::WhatsAppWebhookContext;
|
||||
use super::super::history::{MessagingWindowTracker, WhatsAppConversationHistory};
|
||||
use super::*;
|
||||
use crate::agents::AgentPool;
|
||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||
use crate::io::watcher::WatcherEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
|
||||
/// Build a minimal WhatsAppWebhookContext for allowlist tests.
|
||||
fn make_ctx_with_allowlist(
|
||||
allowed_phones: Vec<String>,
|
||||
) -> Arc<WhatsAppWebhookContext> {
|
||||
fn make_ctx_with_allowlist(allowed_phones: Vec<String>) -> Arc<WhatsAppWebhookContext> {
|
||||
struct NullTransport;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -505,9 +510,15 @@ mod tests {
|
||||
let err = "OAuth session expired or credentials missing. Please log in: http://localhost:3001/oauth/authorize";
|
||||
let url = crate::llm::oauth::extract_login_url_from_error(err);
|
||||
assert!(url.is_some(), "should extract URL from OAuth error");
|
||||
let msg = format!("Authentication required. Log in to Claude here: {}", url.unwrap());
|
||||
let msg = format!(
|
||||
"Authentication required. Log in to Claude here: {}",
|
||||
url.unwrap()
|
||||
);
|
||||
assert!(msg.contains("http://localhost:3001/oauth/authorize"));
|
||||
assert!(!msg.contains('['), "WhatsApp message should not use Markdown link syntax");
|
||||
assert!(
|
||||
!msg.contains('['),
|
||||
"WhatsApp message should not use Markdown link syntax"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -594,7 +605,10 @@ mod tests {
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert!(result.is_none(), "'status' should not be recognised as rebuild");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"'status' should not be recognised as rebuild"
|
||||
);
|
||||
}
|
||||
|
||||
// ── reset command extraction ───────────────────────────────────────
|
||||
@@ -624,14 +638,17 @@ mod tests {
|
||||
let sender = "+15555550100";
|
||||
let history: WhatsAppConversationHistory = Arc::new(TokioMutex::new({
|
||||
let mut m = HashMap::new();
|
||||
m.insert(sender.to_string(), RoomConversation {
|
||||
session_id: Some("old-session".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: sender.to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
});
|
||||
m.insert(
|
||||
sender.to_string(),
|
||||
RoomConversation {
|
||||
session_id: Some("old-session".to_string()),
|
||||
entries: vec![ConversationEntry {
|
||||
role: ConversationRole::User,
|
||||
sender: sender.to_string(),
|
||||
content: "previous message".to_string(),
|
||||
}],
|
||||
},
|
||||
);
|
||||
m
|
||||
}));
|
||||
|
||||
@@ -641,7 +658,9 @@ mod tests {
|
||||
|
||||
{
|
||||
let mut guard = history.lock().await;
|
||||
let conv = guard.entry(sender.to_string()).or_insert_with(RoomConversation::default);
|
||||
let conv = guard
|
||||
.entry(sender.to_string())
|
||||
.or_insert_with(RoomConversation::default);
|
||||
conv.session_id = None;
|
||||
conv.entries.clear();
|
||||
save_whatsapp_history(tmp.path(), &guard);
|
||||
@@ -748,7 +767,10 @@ mod tests {
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert!(result.is_none(), "'status' should not be recognised as rmtree");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"'status' should not be recognised as rmtree"
|
||||
);
|
||||
}
|
||||
|
||||
// ── assign command extraction ──────────────────────────────────────
|
||||
@@ -805,6 +827,9 @@ mod tests {
|
||||
"Timmy",
|
||||
"@timmy:home.local",
|
||||
);
|
||||
assert!(result.is_none(), "'status' should not be recognised as assign");
|
||||
assert!(
|
||||
result.is_none(),
|
||||
"'status' should not be recognised as assign"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,14 +66,11 @@ pub fn markdown_to_whatsapp(text: &str) -> String {
|
||||
LazyLock::new(|| Regex::new(r"(?m)^#{1,6}\s+(.+)$").unwrap());
|
||||
static RE_BOLD_ITALIC: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\*\*\*(.+?)\*\*\*").unwrap());
|
||||
static RE_BOLD: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
static RE_STRIKETHROUGH: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||
static RE_BOLD: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*(.+?)\*\*").unwrap());
|
||||
static RE_STRIKETHROUGH: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"~~(.+?)~~").unwrap());
|
||||
static RE_LINK: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").unwrap());
|
||||
static RE_HR: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
|
||||
static RE_HR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?m)^---+$").unwrap());
|
||||
|
||||
// 1. Protect fenced code blocks by replacing them with placeholders.
|
||||
let mut code_blocks: Vec<String> = Vec::new();
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::history::MessagingWindowTracker;
|
||||
use crate::chat::{ChatTransport, MessageId};
|
||||
use crate::slog;
|
||||
use super::history::MessagingWindowTracker;
|
||||
|
||||
// ── API base URLs (overridable for tests) ────────────────────────────────
|
||||
|
||||
@@ -55,7 +55,11 @@ impl WhatsAppTransport {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
|
||||
pub(crate) fn with_api_base(
|
||||
phone_number_id: String,
|
||||
access_token: String,
|
||||
api_base: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
phone_number_id,
|
||||
access_token,
|
||||
|
||||
@@ -13,9 +13,9 @@ pub mod history;
|
||||
pub mod meta;
|
||||
pub mod twilio;
|
||||
|
||||
pub use history::{load_whatsapp_history, MessagingWindowTracker, WhatsAppConversationHistory};
|
||||
pub use history::{MessagingWindowTracker, WhatsAppConversationHistory, load_whatsapp_history};
|
||||
pub use meta::WhatsAppTransport;
|
||||
pub use twilio::{extract_twilio_text_messages, TwilioWhatsAppTransport};
|
||||
pub use twilio::{TwilioWhatsAppTransport, extract_twilio_text_messages};
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -132,8 +132,7 @@ pub struct WhatsAppWebhookContext {
|
||||
/// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||
pub perm_rx: Arc<TokioMutex<tokio::sync::mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||
/// Pending permission replies keyed by sender phone number.
|
||||
pub pending_perm_replies:
|
||||
Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
pub pending_perm_replies: Arc<TokioMutex<HashMap<String, oneshot::Sender<PermissionDecision>>>>,
|
||||
/// Seconds before an unanswered permission prompt is auto-denied.
|
||||
pub permission_timeout_secs: u64,
|
||||
}
|
||||
|
||||
+14
-11
@@ -202,9 +202,7 @@ pub fn normalize_line_breaks(text: &str) -> String {
|
||||
return true;
|
||||
}
|
||||
// Horizontal rules: lines made entirely of -, *, or _ (at least 3 chars).
|
||||
let all_hr_chars = trimmed
|
||||
.chars()
|
||||
.all(|c| matches!(c, '-' | '*' | '_' | ' '));
|
||||
let all_hr_chars = trimmed.chars().all(|c| matches!(c, '-' | '*' | '_' | ' '));
|
||||
let hr_char_count = trimmed.chars().filter(|c| !c.is_whitespace()).count();
|
||||
all_hr_chars && hr_char_count >= 3
|
||||
}
|
||||
@@ -389,11 +387,7 @@ mod tests {
|
||||
#[test]
|
||||
fn strip_mention_emoji_display_name_no_separator() {
|
||||
// Display name with emoji, no separator
|
||||
let rest = strip_bot_mention(
|
||||
"timmy ⚡️ ambient on",
|
||||
"timmy ⚡️",
|
||||
"@timmy:homeserver.local",
|
||||
);
|
||||
let rest = strip_bot_mention("timmy ⚡️ ambient on", "timmy ⚡️", "@timmy:homeserver.local");
|
||||
assert_eq!(rest, "ambient on");
|
||||
}
|
||||
|
||||
@@ -638,9 +632,18 @@ mod tests {
|
||||
let output = normalize_line_breaks(input);
|
||||
// Prose sentences before and after the code block get doubled.
|
||||
// The code block itself is preserved.
|
||||
assert!(output.contains("First sentence.\n\nSecond sentence."), "prose before code: {output}");
|
||||
assert!(output.contains("```rust\nlet x = 1;\nlet y = 2;\n```"), "code block preserved: {output}");
|
||||
assert!(output.contains("Third sentence.\n\nFourth sentence."), "prose after code: {output}");
|
||||
assert!(
|
||||
output.contains("First sentence.\n\nSecond sentence."),
|
||||
"prose before code: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("```rust\nlet x = 1;\nlet y = 2;\n```"),
|
||||
"code block preserved: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("Third sentence.\n\nFourth sentence."),
|
||||
"prose after code: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user