From 9c914be45a86e54fe65b7b13fd663499fde8f3fa Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 12:54:24 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=E3=82=B5=E3=83=96=E3=82=A8?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E5=BF=9C=E7=AD=94?= =?UTF-8?q?=E3=81=A8=E4=B8=AD=E9=96=93=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AESlack=E9=80=81=E4=BF=A1=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit トランスクリプト・カーソル方式を導入し、エージェントチーム使用時に 欠落していたClaudeの応答をSlackに確実に送信する。 ## 変更内容 - `src/transcript.rs`(新規): JSONL読み取り・カーソル方式でassistantテキスト抽出 - `src/config.rs`: CursorLockGuard追加(fs2によるflock排他制御)、deactivate時にcursor/lockファイルも削除 - `src/formatter.rs`: format_subagent_message / format_notification_message 追加 - `src/hook.rs`: 全イベント共通のflush_transcript実装、SubagentStop/Notification/SessionEndハンドラ追加 - `hooks/hooks.json`: SubagentStop・Notification(elicitation_dialog)・SessionEndイベント登録追加 - `Cargo.toml`: fs2 = "0.4" 追加 ## 解決した問題 - SubagentStop未処理(サブエージェント応答が届かない) - AskUserQuestion/Interruptedでのassistantテキスト欠落(Stop未発火) - 1ターン内の中間テキスト(Agentツール呼び出し前後のテキスト)欠落 Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 33 +++++ Cargo.toml | 1 + hooks/hooks.json | 25 ++++ src/config.rs | 151 +++++++++++++++++++++-- src/formatter.rs | 69 +++++++++++ src/hook.rs | 108 ++++++++++++++++- src/main.rs | 1 + src/transcript.rs | 203 +++++++++++++++++++++++++++++++ tests/integration_test.rs | 247 +++++++++++++++++++++++++++++++++++++- 9 files changed, 825 insertions(+), 13 deletions(-) create mode 100644 src/transcript.rs 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/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..c6b6a96 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,67 @@ impl Config { } } +/// セッションごとのカーソルロックガード +/// acquire() でファイルロックを取得し、commit() で更新・解放する +pub struct CursorLockGuard { + lock_file: std::fs::File, + session_id: String, +} + +impl CursorLockGuard { + /// ファイルロックを排他取得してカーソル値を返す + pub fn acquire(session_id: &str) -> Result<(Self, u64)> { + 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(); + } +} + +fn read_cursor_inner(session_id: &str) -> u64 { + cursor_path(session_id) + .ok() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0) +} + +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 +100,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 +155,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 +181,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 +196,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 +205,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 +214,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, 12345); + }); + } + + #[test] + fn test_cursor_default_zero() { + with_temp_state_dir(|| { + // カーソルファイルがない場合は0 + activate("no-cursor-session").expect("activate失敗"); + let cursor = read_cursor_inner("no-cursor-session"); + assert_eq!(cursor, 0); + }); + } + + #[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=0 + let (guard, cursor) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert_eq!(cursor, 0); + guard.commit(500).expect("commit失敗"); + + // 2回目: cursor=500 + let (guard2, cursor2) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert_eq!(cursor2, 500); + guard2.commit(1000).expect("commit失敗"); + + // 3回目: cursor=1000 + let (_, cursor3) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); + assert_eq!(cursor3, 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..8797eac 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,39 @@ pub async fn handle_toggle() -> Result<()> { Ok(()) } +/// トランスクリプトから未送信のassistantテキストをフラッシュして送信する +/// カーソルロックで同一セッションの並行実行を直列化する +async fn flush_transcript( + session_id: &str, + transcript_path: &str, + ctx: &SessionContext, + sender: &WebhookSender, +) -> Result<()> { + let (lock, cursor) = config::CursorLockGuard::acquire(session_id)?; + let (messages, new_cursor) = + crate::transcript::read_new_assistant_texts(transcript_path, cursor)?; + + for msg in messages { + let payload = formatter::format_assistant_message(&msg, ctx); + sender.send(payload).await?; + } + + // 送信成功後にのみカーソルを更新(at-least-once保証) + lock.commit(new_cursor)?; + Ok(()) +} + 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 +119,14 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); + // トランスクリプト・フラッシュ(全イベント共通) + // 同一セッションの複数フックが同時発火しても直列化される + 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 +137,27 @@ pub async fn handle_hook(event: &str) -> Result<()> { } } "stop" => { + // フラッシュで全assistantテキストが送信済み。追加処理なし。 + } + "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 +202,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 +280,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..ce6a5b8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -33,6 +33,18 @@ impl TestEnv { .expect("config.toml書き込み失敗"); } + /// トランスクリプトファイルを作成してパスを返す + 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 +153,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 +173,19 @@ 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!"}] + } + })]); + 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 +290,227 @@ 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_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; + + // トランスクリプトに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); +} From 57b2c61ddc97f106c01d7ad0f2e9ef6be86b1e9a Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 13:05:11 +0900 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20on/off=E3=82=B3=E3=83=9E=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=81=AE=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=82=92=E6=97=A5=E6=9C=AC=E8=AA=9E=E3=81=AB=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- commands/off.md | 2 +- commands/on.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 ストリーミングが有効になりました。 From 69f06fdd0eb8a48204d6b62dedaf55f080c2bfa6 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 13:11:26 +0900 Subject: [PATCH 3/8] =?UTF-8?q?fix:=20=E6=9C=89=E5=8A=B9=E5=8C=96=E7=9B=B4?= =?UTF-8?q?=E5=BE=8C=E3=81=AE=E5=88=9D=E5=9B=9E=E3=83=95=E3=83=A9=E3=83=83?= =?UTF-8?q?=E3=82=B7=E3=83=A5=E3=81=A7=E9=81=8E=E5=8E=BB=E3=83=A1=E3=83=83?= =?UTF-8?q?=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=8C=E5=A4=A7=E9=87=8F=E9=80=81?= =?UTF-8?q?=E4=BF=A1=E3=81=95=E3=82=8C=E3=82=8B=E5=95=8F=E9=A1=8C=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit カーソルファイルが存在しない(初回有効化・再有効化後)場合は トランスクリプトの末尾にカーソルを設定して過去メッセージを送信しない。 Co-Authored-By: Claude Sonnet 4.6 --- src/config.rs | 34 +++++++++++--------- src/hook.rs | 16 ++++++++- tests/integration_test.rs | 68 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/src/config.rs b/src/config.rs index c6b6a96..05369a2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,7 +34,8 @@ pub struct CursorLockGuard { impl CursorLockGuard { /// ファイルロックを排他取得してカーソル値を返す - pub fn acquire(session_id: &str) -> Result<(Self, u64)> { + /// カーソルファイルが存在しない(初回有効化・再有効化後)場合は 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)); @@ -68,12 +69,15 @@ impl Drop for CursorLockGuard { } } -fn read_cursor_inner(session_id: &str) -> u64 { - cursor_path(session_id) +/// カーソルファイルが存在しない場合は 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(|p| std::fs::read_to_string(p).ok()) .and_then(|s| s.trim().parse().ok()) - .unwrap_or(0) } fn write_cursor_inner(session_id: &str, cursor: u64) -> Result<()> { @@ -223,17 +227,17 @@ url = "https://hooks.slack.com/services/test" activate(session_id).expect("activate失敗"); write_cursor_inner(session_id, 12345).expect("cursor書き込み失敗"); let cursor = read_cursor_inner(session_id); - assert_eq!(cursor, 12345); + assert_eq!(cursor, Some(12345)); }); } #[test] - fn test_cursor_default_zero() { + fn test_cursor_default_none() { with_temp_state_dir(|| { - // カーソルファイルがない場合は0 + // カーソルファイルがない場合は None(初回有効化を検出) activate("no-cursor-session").expect("activate失敗"); let cursor = read_cursor_inner("no-cursor-session"); - assert_eq!(cursor, 0); + assert!(cursor.is_none()); }); } @@ -265,19 +269,19 @@ url = "https://hooks.slack.com/services/test" let session_id = "lock-test-session"; activate(session_id).expect("activate失敗"); - // 初回: cursor=0 + // 初回: cursor=None(カーソルファイルなし = 初回有効化) let (guard, cursor) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); - assert_eq!(cursor, 0); + assert!(cursor.is_none()); guard.commit(500).expect("commit失敗"); - // 2回目: cursor=500 + // 2回目: cursor=Some(500) let (guard2, cursor2) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); - assert_eq!(cursor2, 500); + assert_eq!(cursor2, Some(500)); guard2.commit(1000).expect("commit失敗"); - // 3回目: cursor=1000 + // 3回目: cursor=Some(1000) let (_, cursor3) = CursorLockGuard::acquire(session_id).expect("acquire失敗"); - assert_eq!(cursor3, 1000); + assert_eq!(cursor3, Some(1000)); }); } } diff --git a/src/hook.rs b/src/hook.rs index 8797eac..00dcfa2 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -76,7 +76,21 @@ async fn flush_transcript( ctx: &SessionContext, sender: &WebhookSender, ) -> Result<()> { - let (lock, cursor) = config::CursorLockGuard::acquire(session_id)?; + 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(()); + } + Some(c) => c, + }; + let (messages, new_cursor) = crate::transcript::read_new_assistant_texts(transcript_path, cursor)?; diff --git a/tests/integration_test.rs b/tests/integration_test.rs index ce6a5b8..3a989f8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -33,6 +33,17 @@ 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"); @@ -181,6 +192,9 @@ async fn test_stop_hook_sends_assistant_message_via_transcript() { } })]); + // カーソルを0に設定(フラッシュ済みセッションをシミュレート) + env.set_cursor("test-session-stop", 0); + let input = json!({ "session_id": "test-session-stop", "cwd": "/home/user/proj", @@ -431,6 +445,57 @@ async fn test_session_end_deactivates_session() { ); } +#[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(); @@ -451,6 +516,9 @@ async fn test_flush_transcript_sends_assistant_texts() { }); 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!({ From 934db672dadd450008c36628d0181eef1bc0c6fc Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 13:17:06 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20PreToolUse=20hook=E3=81=A7=E3=81=AE?= =?UTF-8?q?=E3=83=95=E3=83=A9=E3=83=83=E3=82=B7=E3=83=A5=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=EF=BC=88=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E9=80=81?= =?UTF-8?q?=E5=87=BA=E3=82=BF=E3=82=A4=E3=83=9F=E3=83=B3=E3=82=B0=E6=94=B9?= =?UTF-8?q?=E5=96=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ツール呼び出し直前にフラッシュすることで、Claudeのテキストが リアルタイムに近いタイミングでSlackに届くようになる。 割り込み(Interrupted)時もツール呼び出しがあれば大半は送信済みになる。 Co-Authored-By: Claude Sonnet 4.6 --- hooks/hooks.json | 8 ++++++++ src/hook.rs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index 8342c0d..f192736 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -18,6 +18,14 @@ }] } ], + "PreToolUse": [{ + "hooks": [{ + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh pre-tool-use", + "timeout": 30, + "async": true + }] + }], "Stop": [{ "hooks": [{ "type": "command", diff --git a/src/hook.rs b/src/hook.rs index 00dcfa2..42d5c51 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -150,7 +150,7 @@ pub async fn handle_hook(event: &str) -> Result<()> { sender.send(payload).await?; } } - "stop" => { + "pre-tool-use" | "stop" => { // フラッシュで全assistantテキストが送信済み。追加処理なし。 } "subagent-stop" => { From fb05fd11fa5ca1a4740561597d540c0e153655c7 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 13:25:24 +0900 Subject: [PATCH 5/8] =?UTF-8?q?fix:=20Stop=E3=82=A4=E3=83=99=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AEtranscript=E3=83=AC=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=B3=E3=83=B3=E3=83=87=E3=82=A3=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop hookはtranscript書き込み完了前に発火するため、 transcriptフラッシュではなくlast_assistant_messageを直接送信する。 Stop後にカーソルをファイル末尾に進め重複送信を防ぐ。 Co-Authored-By: Claude Sonnet 4.6 --- src/hook.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/hook.rs b/src/hook.rs index 42d5c51..4c7be62 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -133,11 +133,14 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); - // トランスクリプト・フラッシュ(全イベント共通) - // 同一セッションの複数フックが同時発火しても直列化される - 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); + // トランスクリプト・フラッシュ(Stop以外の全イベント共通) + // Stop hookはtranscript書き込み完了前に発火するレースコンディションがあるため除外し、 + // last_assistant_messageを直接使う(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); + } } } @@ -150,8 +153,25 @@ pub async fn handle_hook(event: &str) -> Result<()> { sender.send(payload).await?; } } - "pre-tool-use" | "stop" => { - // フラッシュで全assistantテキストが送信済み。追加処理なし。 + "pre-tool-use" => { + // フラッシュのみ(上で実行済み) + } + "stop" => { + // transcriptへの書き込み完了を待たずにlast_assistant_messageを直接送信 + 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"); From ac1497746c7cb75cd07d4196a4ad40044e6f25a1 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 13:56:38 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20PreToolUse+Stop=E3=81=AE=E4=BA=8C?= =?UTF-8?q?=E9=87=8D=E9=80=81=E4=BF=A1=E3=82=92=E4=BF=AE=E6=AD=A3=EF=BC=88?= =?UTF-8?q?Stop=E5=81=B4=E3=81=AElast=5Fassistant=5Fmessage=E5=BB=83?= =?UTF-8?q?=E6=AD=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUseが遅延してロック取得した際、transcript上の最終テキストも フラッシュしてしまい、Stopがlast_assistant_messageとして同じ最終 テキストを直接送信することで二重送信が発生していた。 StopでもPreToolUse同様にtranscript flushのみを使うよう統一する。 CursorLockGuardによりどちらが先にロックを取得しても整合が保たれる: - PreToolUse先行: 最終テキストを含めてflush → Stop時は新規なし - Stop先行: 最終テキストを含めてflush → PreToolUse時は新規なし Co-Authored-By: Claude Sonnet 4.6 --- src/hook.rs | 31 ++++++++----------------------- 1 file changed, 8 insertions(+), 23 deletions(-) diff --git a/src/hook.rs b/src/hook.rs index 4c7be62..3f09412 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -133,14 +133,12 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); - // トランスクリプト・フラッシュ(Stop以外の全イベント共通) - // Stop hookはtranscript書き込み完了前に発火するレースコンディションがあるため除外し、 - // last_assistant_messageを直接使う(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); - } + // トランスクリプト・フラッシュ(全イベント共通) + // PreToolUse + Stop の二重送信を防ぐため、Stop も transcript flush で統一する。 + // カーソルLockによりどちらのフックが先に実行されても整合が保たれる。 + 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); } } @@ -157,21 +155,8 @@ pub async fn handle_hook(event: &str) -> Result<()> { // フラッシュのみ(上で実行済み) } "stop" => { - // transcriptへの書き込み完了を待たずにlast_assistant_messageを直接送信 - 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); - } - } + // transcript flush で全テキスト送信済み + // last_assistant_message は使わない(PreToolUseとの二重送信防止) } "subagent-stop" => { let agent_type = input.agent_type.as_deref().unwrap_or("Agent"); From 04d94905509d8bb42d313ff0667a21a0a2422ea9 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 16:11:20 +0900 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20Stop=E3=81=AEtranscript=E3=83=AC?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E3=82=B3=E3=83=B3=E3=83=87=E3=82=A3=E3=82=B7?= =?UTF-8?q?=E3=83=A7=E3=83=B3=E3=82=92=E3=83=9D=E3=83=BC=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=B0=E6=96=B9=E5=BC=8F=E3=81=A7=E8=A7=A3=E6=B6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop フックが発火した時点でtranscriptへの書き込みが未完了な場合、 flush で何も読めずメッセージが次のUserPromptSubmitまで遅延していた。 ファイルサイズが安定するまでポーリング(100ms間隔、最大1秒)してから flushするよう変更する。これにより: - レースコンディションが解消(書き込み完了後にflush) - 書き込みが早く完了した場合は短時間で終わる(固定待機より効率的) - PreToolUse + Stop の二重送信もなし(CursorLockによる整合) Co-Authored-By: Claude Sonnet 4.6 --- src/hook.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/hook.rs b/src/hook.rs index 3f09412..11560b8 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -133,6 +133,31 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); + // Stop はtranscript書き込みのレースコンディション対策として、 + // ファイルサイズが安定するまでポーリングしてからflushする。 + // 最低100ms待機し、その後100ms間隔でサイズを確認。 + // 安定(変化なし)になったらflushへ進む(最大1秒でタイムアウト)。 + if event == "stop" { + if let Some(transcript_path) = &input.transcript_path { + // 最低100ms待機(Claude Code の transcript 書き込みを待つ) + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let mut prev_size = std::fs::metadata(transcript_path.as_str()) + .map(|m| m.len()) + .unwrap_or(0); + // 最大9回(+最初の1回で合計最大1秒)ポーリング + for _ in 0..9u32 { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let current_size = std::fs::metadata(transcript_path.as_str()) + .map(|m| m.len()) + .unwrap_or(0); + if current_size == prev_size { + break; // サイズが安定した → 書き込み完了とみなす + } + prev_size = current_size; + } + } + } + // トランスクリプト・フラッシュ(全イベント共通) // PreToolUse + Stop の二重送信を防ぐため、Stop も transcript flush で統一する。 // カーソルLockによりどちらのフックが先に実行されても整合が保たれる。 From b888eca4d7da1e38c622a20c7fefe37c3e2945c3 Mon Sep 17 00:00:00 2001 From: Toshihiro Suzuki Date: Tue, 3 Mar 2026 16:20:26 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20PreToolUse=E5=BB=83=E6=AD=A2?= =?UTF-8?q?=EF=BC=8BStop=E3=81=A7=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E3=83=90=E3=83=83=E3=82=AF=E6=96=B9=E5=BC=8F=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PreToolUseフックを廃止し、Stopイベントでの二重送信リスクを根本的に解消する。 Stopの処理を以下の方式に変更: - transcript flushを試みる(通常ケース) - flush件数が0(= transcript書き込みのレースコンディション)の場合のみ last_assistant_messageにフォールバックしカーソルを末尾に進める PreToolUseがなくなることでflush件数=0は「レースコンディションのみ」になり、 フォールバックが安全に機能する。中間テキストはターン終了時(Stop)にまとめて届く。 Co-Authored-By: Claude Sonnet 4.6 --- hooks/hooks.json | 8 ------ src/hook.rs | 73 ++++++++++++++++++++++++------------------------ 2 files changed, 36 insertions(+), 45 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index f192736..8342c0d 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -18,14 +18,6 @@ }] } ], - "PreToolUse": [{ - "hooks": [{ - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/aloud-code.sh pre-tool-use", - "timeout": 30, - "async": true - }] - }], "Stop": [{ "hooks": [{ "type": "command", diff --git a/src/hook.rs b/src/hook.rs index 11560b8..67d8636 100644 --- a/src/hook.rs +++ b/src/hook.rs @@ -70,12 +70,13 @@ pub async fn handle_toggle() -> Result<()> { /// トランスクリプトから未送信のassistantテキストをフラッシュして送信する /// カーソルロックで同一セッションの並行実行を直列化する +/// 戻り値: 送信したメッセージ数(0 = 新着なし or 初回フラッシュ) async fn flush_transcript( session_id: &str, transcript_path: &str, ctx: &SessionContext, sender: &WebhookSender, -) -> Result<()> { +) -> Result { let (lock, maybe_cursor) = config::CursorLockGuard::acquire(session_id)?; let cursor = match maybe_cursor { @@ -86,7 +87,7 @@ async fn flush_transcript( .map(|m| m.len()) .unwrap_or(0); lock.commit(file_size)?; - return Ok(()); + return Ok(0); } Some(c) => c, }; @@ -94,6 +95,7 @@ async fn flush_transcript( 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?; @@ -101,7 +103,7 @@ async fn flush_transcript( // 送信成功後にのみカーソルを更新(at-least-once保証) lock.commit(new_cursor)?; - Ok(()) + Ok(count) } pub async fn handle_hook(event: &str) -> Result<()> { @@ -133,40 +135,15 @@ pub async fn handle_hook(event: &str) -> Result<()> { let ctx = input.to_session_context(); let sender = WebhookSender::new(webhook_url); - // Stop はtranscript書き込みのレースコンディション対策として、 - // ファイルサイズが安定するまでポーリングしてからflushする。 - // 最低100ms待機し、その後100ms間隔でサイズを確認。 - // 安定(変化なし)になったらflushへ進む(最大1秒でタイムアウト)。 - if event == "stop" { + // トランスクリプト・フラッシュ(Stop以外の全イベント共通) + if event != "stop" { if let Some(transcript_path) = &input.transcript_path { - // 最低100ms待機(Claude Code の transcript 書き込みを待つ) - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut prev_size = std::fs::metadata(transcript_path.as_str()) - .map(|m| m.len()) - .unwrap_or(0); - // 最大9回(+最初の1回で合計最大1秒)ポーリング - for _ in 0..9u32 { - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let current_size = std::fs::metadata(transcript_path.as_str()) - .map(|m| m.len()) - .unwrap_or(0); - if current_size == prev_size { - break; // サイズが安定した → 書き込み完了とみなす - } - prev_size = current_size; + if let Err(e) = flush_transcript(session_id, transcript_path, &ctx, &sender).await { + eprintln!("aloud-code: トランスクリプトフラッシュエラー: {}", e); } } } - // トランスクリプト・フラッシュ(全イベント共通) - // PreToolUse + Stop の二重送信を防ぐため、Stop も transcript flush で統一する。 - // カーソルLockによりどちらのフックが先に実行されても整合が保たれる。 - 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(""); @@ -176,12 +153,34 @@ pub async fn handle_hook(event: &str) -> Result<()> { sender.send(payload).await?; } } - "pre-tool-use" => { - // フラッシュのみ(上で実行済み) - } "stop" => { - // transcript flush で全テキスト送信済み - // last_assistant_message は使わない(PreToolUseとの二重送信防止) + // 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");