diff --git a/Cargo.lock b/Cargo.lock index 2f565ad..29a81f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,7 @@ version = "0.1.0" dependencies = [ "anyhow", "dirs", + "fs2", "reqwest", "serde", "serde_json", @@ -193,6 +194,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -1639,6 +1650,28 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7412877..222d53e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-feature anyhow = "1" toml = "0.8" dirs = "6" +fs2 = "0.4" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/commands/off.md b/commands/off.md index 093c913..29d2b3a 100644 --- a/commands/off.md +++ b/commands/off.md @@ -1 +1 @@ -Run `aloud-code disable` to deactivate Slack streaming for this session. +Slack ストリーミングが無効になりました。 diff --git a/commands/on.md b/commands/on.md index 9a56773..76128b7 100644 --- a/commands/on.md +++ b/commands/on.md @@ -1 +1 @@ -Run `aloud-code enable` to activate Slack streaming for this session. +Slack ストリーミングが有効になりました。 diff --git a/hooks/hooks.json b/hooks/hooks.json index c44fe06..8342c0d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -25,6 +25,31 @@ "timeout": 30, "async": true }] + }], + "SubagentStop": [{ + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh subagent-stop", + "timeout": 30, + "async": true + }] + }], + "Notification": [{ + "matcher": "elicitation_dialog", + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh notification", + "timeout": 30, + "async": true + }] + }], + "SessionEnd": [{ + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh session-end", + "timeout": 30, + "async": true + }] }] } } diff --git a/src/config.rs b/src/config.rs index 62cd3b3..05369a2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use fs2::FileExt; use serde::Deserialize; use std::path::PathBuf; @@ -24,6 +25,71 @@ impl Config { } } +/// セッションごとのカーソルロックガード +/// acquire() でファイルロックを取得し、commit() で更新・解放する +pub struct CursorLockGuard { + lock_file: std::fs::File, + session_id: String, +} + +impl CursorLockGuard { + /// ファイルロックを排他取得してカーソル値を返す + /// カーソルファイルが存在しない(初回有効化・再有効化後)場合は None を返す + pub fn acquire(session_id: &str) -> Result<(Self, Option)> { + let dir = sessions_dir()?; + std::fs::create_dir_all(&dir)?; + let lock_path = dir.join(format!("{}.cursor.lock", session_id)); + let lock_file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(false) + .open(&lock_path)?; + lock_file.lock_exclusive()?; + let cursor = read_cursor_inner(session_id); + Ok(( + CursorLockGuard { + lock_file, + session_id: session_id.to_string(), + }, + cursor, + )) + } + + /// カーソルを新しい値に更新してロックを解放する(送信成功後に呼び出す) + pub fn commit(self, new_cursor: u64) -> Result<()> { + write_cursor_inner(&self.session_id, new_cursor)?; + self.lock_file.unlock()?; + Ok(()) + } +} + +impl Drop for CursorLockGuard { + fn drop(&mut self) { + let _ = self.lock_file.unlock(); + } +} + +/// カーソルファイルが存在しない場合は None を返す(初回有効化を検出するため) +fn read_cursor_inner(session_id: &str) -> Option { + let path = cursor_path(session_id).ok()?; + if !path.exists() { + return None; + } + std::fs::read_to_string(path) + .ok() + .and_then(|s| s.trim().parse().ok()) +} + +fn write_cursor_inner(session_id: &str, cursor: u64) -> Result<()> { + let path = cursor_path(session_id)?; + std::fs::write(path, cursor.to_string())?; + Ok(()) +} + +fn cursor_path(session_id: &str) -> Result { + Ok(sessions_dir()?.join(format!("{}.cursor", session_id))) +} + pub fn is_active(session_id: &str) -> bool { sessions_dir() .map(|d| d.join(session_id).exists()) @@ -38,12 +104,20 @@ pub fn activate(session_id: &str) -> Result<()> { } pub fn deactivate(session_id: &str) -> Result<()> { - let path = sessions_dir()?.join(session_id); - match std::fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(e.into()), + let dir = sessions_dir()?; + let paths = [ + dir.join(session_id), + dir.join(format!("{}.cursor", session_id)), + dir.join(format!("{}.cursor.lock", session_id)), + ]; + for path in &paths { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } } + Ok(()) } fn config_file_path() -> Result { @@ -85,14 +159,12 @@ mod tests { #[test] fn test_config_default_when_no_file() { - // デフォルト設定が返ることを確認(ファイルが存在しない場合) let config = Config::default(); assert!(config.webhook.url.is_none()); } #[test] fn test_config_parse_webhook_url() { - // TOMLパースのテスト let toml_str = r#" [webhook] url = "https://hooks.slack.com/services/test" @@ -113,7 +185,6 @@ url = "https://hooks.slack.com/services/test" #[test] fn test_active_flag_lifecycle() { - // セッションIDごとのフラグ作成・確認・削除をテスト with_temp_state_dir(|| { let session_id = "test-session-lifecycle"; let _ = deactivate(session_id); @@ -129,7 +200,6 @@ url = "https://hooks.slack.com/services/test" #[test] fn test_deactivate_idempotent() { - // フラグが存在しなくてもdeactivateはエラーにならない with_temp_state_dir(|| { let _ = deactivate("nonexistent-session"); let result = deactivate("nonexistent-session"); @@ -139,7 +209,6 @@ url = "https://hooks.slack.com/services/test" #[test] fn test_multiple_sessions_concurrent() { - // 複数セッションが同時にONにできることを確認 with_temp_state_dir(|| { activate("session-a").expect("session-a activate失敗"); activate("session-b").expect("session-b activate失敗"); @@ -149,4 +218,70 @@ url = "https://hooks.slack.com/services/test" assert!(!is_active("session-c"), "session-cがアクティブになっている"); }); } + + #[test] + fn test_cursor_read_write() { + with_temp_state_dir(|| { + let session_id = "cursor-test-session"; + // セッションを有効化してディレクトリを作成 + activate(session_id).expect("activate失敗"); + write_cursor_inner(session_id, 12345).expect("cursor書き込み失敗"); + let cursor = read_cursor_inner(session_id); + assert_eq!(cursor, Some(12345)); + }); + } + + #[test] + fn test_cursor_default_none() { + with_temp_state_dir(|| { + // カーソルファイルがない場合は None(初回有効化を検出) + activate("no-cursor-session").expect("activate失敗"); + let cursor = read_cursor_inner("no-cursor-session"); + assert!(cursor.is_none()); + }); + } + + #[test] + fn test_cursor_deleted_on_deactivate() { + with_temp_state_dir(|| { + let session_id = "deactivate-cursor-session"; + activate(session_id).expect("activate失敗"); + write_cursor_inner(session_id, 999).expect("cursor書き込み失敗"); + + let dir = sessions_dir().unwrap(); + assert!(dir.join(format!("{}.cursor", session_id)).exists()); + + deactivate(session_id).expect("deactivate失敗"); + assert!( + !dir.join(session_id).exists(), + "セッションファイルが残っている" + ); + assert!( + !dir.join(format!("{}.cursor", session_id)).exists(), + "cursorファイルが残っている" + ); + }); + } + + #[test] + fn test_cursor_lock_acquire_commit() { + with_temp_state_dir(|| { + let session_id = "lock-test-session"; + activate(session_id).expect("activate失敗"); + + // 初回: cursor=None(カーソルファイルなし = 初回有効化) + let (guard, cursor) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert!(cursor.is_none()); + guard.commit(500).expect("commit失敗"); + + // 2回目: cursor=Some(500) + let (guard2, cursor2) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert_eq!(cursor2, Some(500)); + guard2.commit(1000).expect("commit失敗"); + + // 3回目: cursor=Some(1000) + let (_, cursor3) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert_eq!(cursor3, Some(1000)); + }); + } } diff --git a/src/formatter.rs b/src/formatter.rs index a68528e..5100241 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -74,6 +74,38 @@ pub fn format_assistant_message(message: &str, ctx: &SessionContext) -> Value { }) } +pub fn format_subagent_message(agent_type: &str, message: &str, ctx: &SessionContext) -> Value { + let text = truncate(message, MAX_BLOCK_TEXT_LEN); + json!({ + "username": ctx.username(), + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!(":gear: *Agent ({})*\n{}", agent_type, text) + } + } + ] + }) +} + +pub fn format_notification_message(message: &str, ctx: &SessionContext) -> Value { + let text = truncate(message, MAX_BLOCK_TEXT_LEN); + json!({ + "username": ctx.username(), + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!(":speech_balloon: *Claude → User*\n{}", text) + } + } + ] + }) +} + #[cfg(test)] mod tests { use super::*; @@ -138,6 +170,43 @@ mod tests { assert!(text.len() <= MAX_BLOCK_TEXT_LEN + 50); } + #[test] + fn test_format_subagent_message_structure() { + let ctx = test_ctx(); + let payload = format_subagent_message("Explore", "Found relevant files.", &ctx); + + let blocks = payload["blocks"].as_array().unwrap(); + let text = &blocks[0]["text"]["text"]; + assert!(text.as_str().unwrap().contains(":gear:")); + assert!(text.as_str().unwrap().contains("Agent (Explore)")); + assert!(text.as_str().unwrap().contains("Found relevant files.")); + } + + #[test] + fn test_format_subagent_message_truncation() { + let ctx = test_ctx(); + let long_text = "x".repeat(4000); + let payload = format_subagent_message("Agent", &long_text, &ctx); + + let text = payload["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!(text.len() <= MAX_BLOCK_TEXT_LEN + 50); + } + + #[test] + fn test_format_notification_message_structure() { + let ctx = test_ctx(); + let payload = format_notification_message("Which approach do you prefer?", &ctx); + + let blocks = payload["blocks"].as_array().unwrap(); + let text = &blocks[0]["text"]["text"]; + assert!(text.as_str().unwrap().contains(":speech_balloon:")); + assert!(text.as_str().unwrap().contains("Claude → User")); + assert!(text + .as_str() + .unwrap() + .contains("Which approach do you prefer?")); + } + #[test] fn test_slack_emoji_codes_not_unicode() { let ctx = test_ctx(); diff --git a/src/hook.rs b/src/hook.rs index a51186b..67d8636 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -17,6 +17,11 @@ pub struct HookInput { pub last_assistant_message: Option, pub reason: Option, pub model: Option, + // SubagentStop用 + pub agent_id: Option, + pub agent_type: Option, + // Notification用 + pub message: Option, } impl HookInput { @@ -63,9 +68,55 @@ pub async fn handle_toggle() -> Result<()> { Ok(()) } +/// トランスクリプトから未送信のassistantテキストをフラッシュして送信する +/// カーソルロックで同一セッションの並行実行を直列化する +/// 戻り値: 送信したメッセージ数(0 = 新着なし or 初回フラッシュ) +async fn flush_transcript( + session_id: &str, + transcript_path: &str, + ctx: &SessionContext, + sender: &WebhookSender, +) -> Result { + let (lock, maybe_cursor) = config::CursorLockGuard::acquire(session_id)?; + + let cursor = match maybe_cursor { + None => { + // カーソルファイルが存在しない = 有効化直後の初回フラッシュ + // 過去メッセージは送信せず、現在のファイル末尾にカーソルを設定する + let file_size = std::fs::metadata(transcript_path) + .map(|m| m.len()) + .unwrap_or(0); + lock.commit(file_size)?; + return Ok(0); + } + Some(c) => c, + }; + + let (messages, new_cursor) = + crate::transcript::read_new_assistant_texts(transcript_path, cursor)?; + + let count = messages.len(); + for msg in messages { + let payload = formatter::format_assistant_message(&msg, ctx); + sender.send(payload).await?; + } + + // 送信成功後にのみカーソルを更新(at-least-once保証) + lock.commit(new_cursor)?; + Ok(count) +} + pub async fn handle_hook(event: &str) -> Result<()> { let input = HookInput::from_stdin()?; - let session_id = input.session_id.as_deref().unwrap_or(""); + let session_id = match input.session_id.as_deref() { + Some(id) if !id.is_empty() => id, + _ => { + // session_id欠損時は処理中断(セッション混線防止) + eprintln!("aloud-code: session_idが空のため処理をスキップ"); + println!("{{}}"); + return Ok(()); + } + }; if !config::is_active(session_id) { println!("{{}}"); @@ -84,6 +135,15 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); + // トランスクリプト・フラッシュ(Stop以外の全イベント共通) + if event != "stop" { + if let Some(transcript_path) = &input.transcript_path { + if let Err(e) = flush_transcript(session_id, transcript_path, &ctx, &sender).await { + eprintln!("aloud-code: トランスクリプトフラッシュエラー: {}", e); + } + } + } + match event { "user-prompt" => { let prompt = input.prompt.as_deref().unwrap_or(""); @@ -94,12 +154,53 @@ pub async fn handle_hook(event: &str) -> Result<()> { } } "stop" => { + // transcript flush を試みる。 + // 0件(= transcript 書き込みがまだ完了していないレースコンディション)の場合のみ + // last_assistant_message にフォールバックしてカーソルを末尾に進める。 + // PreToolUse を廃止したため二重送信は起きない。 + let flushed = if let Some(transcript_path) = &input.transcript_path { + flush_transcript(session_id, transcript_path, &ctx, &sender) + .await + .unwrap_or(0) + } else { + 0 + }; + if flushed == 0 { + let message = input.last_assistant_message.as_deref().unwrap_or(""); + if !message.is_empty() { + let payload = formatter::format_assistant_message(message, &ctx); + sender.send(payload).await?; + } + // カーソルをファイル末尾に進め、次回UserPromptSubmitでの重複送信を防ぐ + if let Some(transcript_path) = &input.transcript_path { + let file_size = std::fs::metadata(transcript_path.as_str()) + .map(|m| m.len()) + .unwrap_or(0); + if let Ok((lock, _)) = config::CursorLockGuard::acquire(session_id) { + let _ = lock.commit(file_size); + } + } + } + } + "subagent-stop" => { + let agent_type = input.agent_type.as_deref().unwrap_or("Agent"); let message = input.last_assistant_message.as_deref().unwrap_or(""); if !message.is_empty() { - let payload = formatter::format_assistant_message(message, &ctx); + let payload = formatter::format_subagent_message(agent_type, message, &ctx); sender.send(payload).await?; } } + "notification" => { + let message = input.message.as_deref().unwrap_or(""); + if !message.is_empty() { + let payload = formatter::format_notification_message(message, &ctx); + sender.send(payload).await?; + } + } + "session-end" => { + // セッション非アクティブ化(カーソル+ロックファイルも削除) + config::deactivate(session_id)?; + } unknown => { eprintln!("aloud-code: 未知のhookイベント: {}", unknown); } @@ -144,6 +245,40 @@ mod tests { ); } + #[test] + fn test_deserialize_subagent_stop_input() { + let json = r#"{ + "session_id": "abc123", + "cwd": "/home/user/project", + "hook_event_name": "SubagentStop", + "agent_id": "agent-xyz", + "agent_type": "Explore", + "last_assistant_message": "Found 5 matching files." + }"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!(input.agent_id.as_deref(), Some("agent-xyz")); + assert_eq!(input.agent_type.as_deref(), Some("Explore")); + assert_eq!( + input.last_assistant_message.as_deref(), + Some("Found 5 matching files.") + ); + } + + #[test] + fn test_deserialize_notification_input() { + let json = r#"{ + "session_id": "abc123", + "cwd": "/home/user/project", + "hook_event_name": "Notification", + "message": "Which option do you prefer?" + }"#; + let input: HookInput = serde_json::from_str(json).unwrap(); + assert_eq!( + input.message.as_deref(), + Some("Which option do you prefer?") + ); + } + #[test] fn test_deserialize_session_end_input() { let json = r#"{ @@ -188,4 +323,16 @@ mod tests { assert!(!is_toggle_command("/aloud-code:on extra")); // 余分なテキスト assert!(!is_toggle_command("")); } + + #[test] + fn test_empty_session_id_skipped() { + // session_idが空のHookInputはガード条件に引っかかる + let input = HookInput { + session_id: Some("".to_string()), + ..Default::default() + }; + let id = input.session_id.as_deref(); + let is_empty_or_missing = matches!(id, Some("") | None); + assert!(is_empty_or_missing); + } } diff --git a/src/main.rs b/src/main.rs index d4743a1..7ded157 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod config; mod formatter; mod hook; +mod transcript; mod webhook; #[tokio::main] diff --git a/src/transcript.rs b/src/transcript.rs new file mode 100644 index 0000000..89a0659 --- /dev/null +++ b/src/transcript.rs @@ -0,0 +1,203 @@ +use anyhow::Result; +use serde_json::Value; +use std::io::{BufRead, Seek, SeekFrom}; + +/// トランスクリプトJSONLファイルからカーソル位置以降の新しいassistantテキストを読み取る +/// 戻り値: (テキストメッセージのVec, 新しいカーソル位置) +pub fn read_new_assistant_texts(path: &str, cursor: u64) -> Result<(Vec, u64)> { + let file_size = std::fs::metadata(path)?.len(); + // cursor > filesize の場合はリセット(トランスクリプト再作成等への対応) + let effective_cursor = if cursor > file_size { 0 } else { cursor }; + + let file = std::fs::File::open(path)?; + let mut reader = std::io::BufReader::new(file); + reader.seek(SeekFrom::Start(effective_cursor))?; + + let mut messages = Vec::new(); + let mut last_complete_pos = effective_cursor; + + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line)?; + if n == 0 { + break; + } + + // \nで終わらない行は書き込み中の不完全行のためスキップ + // カーソルはこの行の開始位置(last_complete_pos)に留まる + if !line.ends_with('\n') { + break; + } + + last_complete_pos += n as u64; + + if let Ok(entry) = serde_json::from_str::(&line) { + if entry["type"] == "assistant" { + if let Some(content) = entry["message"]["content"].as_array() { + let parts: Vec<&str> = content + .iter() + .filter_map(|b| { + if b["type"] == "text" { + b["text"].as_str() + } else { + None + } + }) + .collect(); + let text = parts.join("\n"); + if !text.is_empty() { + messages.push(text); + } + } + } + } + } + + Ok((messages, last_complete_pos)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_jsonl_file(lines: &[String]) -> tempfile::NamedTempFile { + let mut f = tempfile::NamedTempFile::new().unwrap(); + for line in lines { + writeln!(f, "{}", line).unwrap(); + } + f.flush().unwrap(); + f + } + + fn assistant_text_entry(text: &str) -> String { + serde_json::json!({ + "type": "assistant", + "message": { + "content": [{"type": "text", "text": text}] + } + }) + .to_string() + } + + fn assistant_tool_use_entry() -> String { + serde_json::json!({ + "type": "assistant", + "message": { + "content": [{"type": "tool_use", "name": "Bash", "id": "t1"}] + } + }) + .to_string() + } + + fn user_entry(text: &str) -> String { + serde_json::json!({ + "type": "user", + "message": {"content": [{"type": "text", "text": text}]} + }) + .to_string() + } + + #[test] + fn test_read_new_assistant_texts() { + let f = write_jsonl_file(&[assistant_text_entry("hello world")]); + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert_eq!(texts, vec!["hello world"]); + } + + #[test] + fn test_skip_tool_use_entries() { + let f = write_jsonl_file(&[assistant_tool_use_entry()]); + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert!(texts.is_empty()); + } + + #[test] + fn test_skip_empty_entries() { + let entry = serde_json::json!({ + "type": "assistant", + "message": {"content": []} + }) + .to_string(); + let f = write_jsonl_file(&[entry]); + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert!(texts.is_empty()); + } + + #[test] + fn test_skip_non_assistant_entries() { + let f = write_jsonl_file(&[ + user_entry("hi"), + r#"{"type":"progress","data":{}}"#.to_string(), + r#"{"type":"system","content":""}"#.to_string(), + ]); + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert!(texts.is_empty()); + } + + #[test] + fn test_cursor_offset() { + let line1 = assistant_text_entry("first"); + let line2 = assistant_text_entry("second"); + let first_line_bytes = (line1.len() + 1) as u64; // +1 for \n + let f = write_jsonl_file(&[line1, line2]); + + let (texts, _) = + read_new_assistant_texts(f.path().to_str().unwrap(), first_line_bytes).unwrap(); + assert_eq!(texts, vec!["second"]); + } + + #[test] + fn test_incomplete_line_skipped() { + let mut f = tempfile::NamedTempFile::new().unwrap(); + let complete = assistant_text_entry("complete"); + let incomplete = assistant_text_entry("incomplete"); + write!(f, "{}\n", complete).unwrap(); + write!(f, "{}", incomplete).unwrap(); // \nなし + f.flush().unwrap(); + + let (texts, cursor) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert_eq!(texts, vec!["complete"]); + // カーソルは完全な行の終わりまで + let expected_cursor = (complete.len() + 1) as u64; // +1 for \n + assert_eq!(cursor, expected_cursor); + } + + #[test] + fn test_cursor_beyond_filesize_resets() { + let f = write_jsonl_file(&[assistant_text_entry("text")]); + // cursor > filesize → 0にリセットして全読み取り + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 99999).unwrap(); + assert_eq!(texts, vec!["text"]); + } + + #[test] + fn test_multiple_text_blocks_joined() { + let entry = serde_json::json!({ + "type": "assistant", + "message": { + "content": [ + {"type": "text", "text": "part1"}, + {"type": "tool_use", "name": "Bash"}, + {"type": "text", "text": "part2"} + ] + } + }) + .to_string(); + let f = write_jsonl_file(&[entry]); + let (texts, _) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert_eq!(texts, vec!["part1\npart2"]); + } + + #[test] + fn test_returns_new_cursor_position() { + let line1 = assistant_text_entry("msg1"); + let line2 = assistant_text_entry("msg2"); + let expected_bytes = (line1.len() + 1 + line2.len() + 1) as u64; + let f = write_jsonl_file(&[line1, line2]); + + let (texts, cursor) = read_new_assistant_texts(f.path().to_str().unwrap(), 0).unwrap(); + assert_eq!(texts.len(), 2); + assert_eq!(cursor, expected_bytes); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index bf90bae..3a989f8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -33,6 +33,29 @@ impl TestEnv { .expect("config.toml書き込み失敗"); } + /// セッションのカーソル値を手動設定する(テスト用) + fn set_cursor(&self, session_id: &str, value: u64) { + let sessions_dir = self.state_dir.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + std::fs::write( + sessions_dir.join(format!("{}.cursor", session_id)), + value.to_string(), + ) + .unwrap(); + } + + /// トランスクリプトファイルを作成してパスを返す + fn create_transcript(&self, entries: &[serde_json::Value]) -> std::path::PathBuf { + let transcript_path = self._temp_dir.path().join("transcript.jsonl"); + let mut content = String::new(); + for entry in entries { + content.push_str(&entry.to_string()); + content.push('\n'); + } + std::fs::write(&transcript_path, &content).expect("トランスクリプト書き込み失敗"); + transcript_path + } + async fn run_hook(&self, event: &str, input_json: &str) -> std::process::Output { use std::io::Write; use std::process::{Command, Stdio}; @@ -141,7 +164,7 @@ async fn test_no_webhook_when_disabled() { } #[tokio::test] -async fn test_stop_hook_sends_assistant_message() { +async fn test_stop_hook_sends_assistant_message_via_transcript() { let env = TestEnv::new(); let mock_server = MockServer::start().await; @@ -161,10 +184,22 @@ async fn test_stop_hook_sends_assistant_message() { let output = env.run_hook("toggle", &toggle_input.to_string()).await; assert!(output.status.success(), "toggle失敗"); + // トランスクリプトファイルにassistantメッセージを書き込む + let transcript_path = env.create_transcript(&[json!({ + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "I've completed the task!"}] + } + })]); + + // カーソルを0に設定(フラッシュ済みセッションをシミュレート) + env.set_cursor("test-session-stop", 0); + let input = json!({ "session_id": "test-session-stop", "cwd": "/home/user/proj", "hook_event_name": "Stop", + "transcript_path": transcript_path.to_str().unwrap(), "last_assistant_message": "I've completed the task!" }); let output = env.run_hook("stop", &input.to_string()).await; @@ -269,3 +304,281 @@ async fn test_no_webhook_for_different_session() { "異なるセッションIDなのにWebhookが届いた" ); } + +#[tokio::test] +async fn test_subagent_stop_sends_agent_message() { + let env = TestEnv::new(); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&mock_server) + .await; + + env.set_webhook_url(&format!("{}/webhook", mock_server.uri())); + + // セッションON + let toggle_on = json!({ + "session_id": "subagent-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "/aloud-code:on" + }); + env.run_hook("toggle", &toggle_on.to_string()).await; + + let input = json!({ + "session_id": "subagent-session", + "cwd": "/home/user/proj", + "hook_event_name": "SubagentStop", + "agent_type": "Explore", + "last_assistant_message": "Found 3 relevant files." + }); + let output = env.run_hook("subagent-stop", &input.to_string()).await; + assert!( + output.status.success(), + "subagent-stop hook失敗: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = mock_server.received_requests().await.unwrap(); + assert!(!requests.is_empty(), "SubagentStopでWebhookが届いていない"); + + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let text = body["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!(text.contains(":gear:"), "gear絵文字がない"); + assert!(text.contains("Explore"), "agent_typeが含まれていない"); + assert!( + text.contains("Found 3 relevant files."), + "サブエージェントメッセージが含まれていない: {}", + text + ); +} + +#[tokio::test] +async fn test_notification_sends_message() { + let env = TestEnv::new(); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&mock_server) + .await; + + env.set_webhook_url(&format!("{}/webhook", mock_server.uri())); + + // セッションON + let toggle_on = json!({ + "session_id": "notif-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "/aloud-code:on" + }); + env.run_hook("toggle", &toggle_on.to_string()).await; + + let input = json!({ + "session_id": "notif-session", + "cwd": "/home/user/proj", + "hook_event_name": "Notification", + "message": "Which approach do you prefer?" + }); + let output = env.run_hook("notification", &input.to_string()).await; + assert!( + output.status.success(), + "notification hook失敗: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = mock_server.received_requests().await.unwrap(); + assert!(!requests.is_empty(), "NotificationでWebhookが届いていない"); + + let body: serde_json::Value = serde_json::from_slice(&requests[0].body).unwrap(); + let text = body["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!( + text.contains(":speech_balloon:"), + "speech_balloon絵文字がない" + ); + assert!( + text.contains("Which approach do you prefer?"), + "通知メッセージが含まれていない: {}", + text + ); +} + +#[tokio::test] +async fn test_session_end_deactivates_session() { + let env = TestEnv::new(); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + env.set_webhook_url(&format!("{}/webhook", mock_server.uri())); + + // セッションON + let toggle_on = json!({ + "session_id": "end-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "/aloud-code:on" + }); + env.run_hook("toggle", &toggle_on.to_string()).await; + + let sessions_dir = env.state_dir.join("sessions"); + assert!(sessions_dir.join("end-session").exists(), "ONのはず"); + + // session-end を実行 + let input = json!({ + "session_id": "end-session", + "cwd": "/home/user/proj", + "hook_event_name": "SessionEnd" + }); + let output = env.run_hook("session-end", &input.to_string()).await; + assert!( + output.status.success(), + "session-end hook失敗: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // セッションが非アクティブになっていることを確認 + assert!( + !sessions_dir.join("end-session").exists(), + "session-end後もフラグが残っている" + ); +} + +#[tokio::test] +async fn test_no_historical_messages_on_first_activation() { + // 有効化直後の初回フラッシュで過去メッセージが送信されないことを確認 + let env = TestEnv::new(); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&mock_server) + .await; + + env.set_webhook_url(&format!("{}/webhook", mock_server.uri())); + + // セッションON + let toggle_on = json!({ + "session_id": "fresh-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "/aloud-code:on" + }); + env.run_hook("toggle", &toggle_on.to_string()).await; + + // 有効化前から存在するトランスクリプト(過去メッセージ) + let transcript_path = env.create_transcript(&[json!({ + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "This is a historical message."}] + } + })]); + + // 初回フラッシュ: カーソルファイルなし → 過去メッセージは送信しない + let input = json!({ + "session_id": "fresh-session", + "cwd": "/home/user/proj", + "hook_event_name": "Stop", + "transcript_path": transcript_path.to_str().unwrap() + }); + let output = env.run_hook("stop", &input.to_string()).await; + assert!( + output.status.success(), + "stop hook失敗: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = mock_server.received_requests().await.unwrap(); + assert!( + requests.is_empty(), + "初回フラッシュで過去メッセージが送信された({}件)", + requests.len() + ); +} + +#[tokio::test] +async fn test_flush_transcript_sends_assistant_texts() { + let env = TestEnv::new(); + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({"ok": true}))) + .mount(&mock_server) + .await; + + env.set_webhook_url(&format!("{}/webhook", mock_server.uri())); + + // セッションON + let toggle_on = json!({ + "session_id": "flush-session", + "hook_event_name": "UserPromptSubmit", + "prompt": "/aloud-code:on" + }); + env.run_hook("toggle", &toggle_on.to_string()).await; + + // カーソルを0に設定(フラッシュ済みセッションをシミュレート) + env.set_cursor("flush-session", 0); + + // トランスクリプトに2つのassistantメッセージ + let transcript_path = env.create_transcript(&[ + json!({ + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "First assistant response."}] + } + }), + json!({ + "type": "user", + "message": {"content": [{"type": "text", "text": "user msg"}]} + }), + json!({ + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Second assistant response."}] + } + }), + ]); + + // SubagentStopイベントでフラッシュが走る + let input = json!({ + "session_id": "flush-session", + "cwd": "/home/user/proj", + "hook_event_name": "SubagentStop", + "agent_type": "general-purpose", + "last_assistant_message": "Subagent result.", + "transcript_path": transcript_path.to_str().unwrap() + }); + let output = env.run_hook("subagent-stop", &input.to_string()).await; + assert!( + output.status.success(), + "subagent-stop hook失敗: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let requests = mock_server.received_requests().await.unwrap(); + // フラッシュ2件 + サブエージェント固有1件 = 合計3件 + assert_eq!(requests.len(), 3, "送信件数が期待と異なる: {:?}", requests); + + // 最初の2件はフラッシュ(assistantテキスト) + let text0 = requests[0].body_json::().unwrap(); + let text0 = text0["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!( + text0.contains("First assistant response."), + "1件目: {}", + text0 + ); + + let text1 = requests[1].body_json::().unwrap(); + let text1 = text1["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!( + text1.contains("Second assistant response."), + "2件目: {}", + text1 + ); + + // 3件目はサブエージェント固有の送信 + let text2 = requests[2].body_json::().unwrap(); + let text2 = text2["blocks"][0]["text"]["text"].as_str().unwrap(); + assert!(text2.contains(":gear:"), "3件目にgear絵文字がない"); + assert!(text2.contains("Subagent result."), "3件目: {}", text2); +}