diff --git a/README.md b/README.md index 5263da2..332c144 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,13 @@ This is the original mode. It tails a Codex session log under `~/.codex/sessions This mode uses Codex Hooks instead of transcript tailing. - One Slack thread per Codex `session_id` -- `SessionStart` creates the session thread root if needed -- `UserPromptSubmit` posts the user prompt into that thread +- The first `UserPromptSubmit` becomes the Slack thread root +- Later `UserPromptSubmit` events in the same session are posted as replies in that thread +- `SessionStart` is accepted but does not post a Slack message - `PreToolUse` and `PostToolUse` can post Bash tool activity - `Stop` posts `last_assistant_message` for the completed turn -This keeps all prompts and replies for the same Codex session in one Slack thread and does not require tailing a session log. +This keeps all prompts and replies for the same Codex session in one Slack thread and does not require tailing a session log. It also avoids a separate "hook started" root message. ## How It Works @@ -53,7 +54,7 @@ This keeps all prompts and replies for the same Codex session in one Slack threa 1. Codex invokes `bin/codex-notify-hook` for configured hook events. 2. The hook command reads the JSON payload from standard input. -3. A per-session Slack thread is created and stored on first use. +3. The first `UserPromptSubmit` creates the per-session Slack thread and stores its thread timestamp. 4. Later hook events for the same `session_id` are posted into the same thread. ## Supported Behavior @@ -66,8 +67,10 @@ This keeps all prompts and replies for the same Codex session in one Slack threa - optional thread replies for `command_execution`, `file_change`, `web_search`, and other completed items - Hook mode: - one Slack thread per Codex session - - prompt, Bash tool activity, and final assistant messages posted from hook events + - the first user prompt becomes the thread root + - prompt replies, Bash tool activity, and final assistant messages posted from hook events - local state file used to remember Slack thread timestamps across hook invocations + - a user prompt containing only `---` resets the current session thread without posting to Slack - Shared: - long payloads are split into safe chunks before posting - `.env` loading via `dotenv` @@ -305,6 +308,7 @@ Notes: - Hook config uses matcher groups. Each event contains an array of groups, and each group contains a `hooks` array of handlers. - `SessionStart`, `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` are the event names. +- In hook mode, a prompt containing only `---` clears the saved Slack thread for that Codex session. The next user prompt starts a new Slack thread. - This executable pins `BUNDLE_GEMFILE` to its own project, so it can be launched from other repositories without resolving the wrong `Gemfile`. - The hook implementation keeps normal successful runs quiet so Codex does not show extra debug-style output from the hook itself. diff --git a/lib/codex_notify/hook_runner.rb b/lib/codex_notify/hook_runner.rb index fa78b46..13ac212 100644 --- a/lib/codex_notify/hook_runner.rb +++ b/lib/codex_notify/hook_runner.rb @@ -8,6 +8,8 @@ module CodexNotify class HookRunner + RESET_THREAD_PROMPT = '---' + EVENT_ALIASES = { 'userpromptsubmit' => 'UserPromptSubmit', 'pretooluse' => 'PreToolUse', @@ -67,23 +69,17 @@ def thread_for(session_id) @store.thread_ts_for(session_id) end - def ensure_session_thread(payload) + def ensure_session_thread(payload, root_text: nil) session_id = session_id_from(payload) || '__default__' thread_ts = thread_for(session_id) - return [session_id, thread_ts] if thread_ts - - cwd = cwd_from(payload) - title = @title || "Codex session: #{File.basename(cwd)}" - root_text = HookFormatter.session_root_text( - title: title, - cwd: cwd, - session_id: session_id, - user_name: @user_name - ) + return [session_id, thread_ts, false] if thread_ts + + return [session_id, nil, false] if root_text.nil? || root_text.to_s.empty? + response = @client.post(root_text) thread_ts = response.fetch('ts').to_s @store.save_thread_ts(session_id, thread_ts) - [session_id, thread_ts] + [session_id, thread_ts, true] end def handle_session_start(payload) @@ -91,29 +87,49 @@ def handle_session_start(payload) end def handle_user_prompt_submit(payload) - _session_id, thread_ts = ensure_session_thread(payload) prompt = payload['prompt'] || payload.dig('payload', 'prompt') - unless prompt.nil? || prompt.to_s.empty? - @client.post(HookFormatter.prompt_text(user_name: @user_name, prompt: prompt), thread_ts: thread_ts) + return if prompt.nil? || prompt.to_s.empty? + + session_id = session_id_from(payload) || '__default__' + if reset_thread_prompt?(prompt) + @store.clear_thread(session_id) + return end + + prompt_text = HookFormatter.prompt_text(user_name: @user_name, prompt: prompt) + _session_id, thread_ts, created = ensure_session_thread(payload, root_text: prompt_text) + return if thread_ts.nil? + return if created + + @client.post(prompt_text, thread_ts: thread_ts) end def handle_pre_tool_use(payload) - _session_id, thread_ts = ensure_session_thread(payload) + _session_id, thread_ts, = ensure_session_thread(payload) + return if thread_ts.nil? + @client.post(HookFormatter.format_pre_tool(payload), thread_ts: thread_ts) end def handle_post_tool_use(payload) - _session_id, thread_ts = ensure_session_thread(payload) + _session_id, thread_ts, = ensure_session_thread(payload) + return if thread_ts.nil? + @client.post(HookFormatter.format_post_tool(payload), thread_ts: thread_ts) end def handle_stop(payload) - _session_id, thread_ts = ensure_session_thread(payload) + _session_id, thread_ts, = ensure_session_thread(payload) + return if thread_ts.nil? + message = payload['last_assistant_message'] || payload.dig('payload', 'last_assistant_message') unless message.nil? || message.to_s.empty? @client.post(HookFormatter.assistant_text(message), thread_ts: thread_ts) end end + + def reset_thread_prompt?(prompt) + prompt.to_s.strip == RESET_THREAD_PROMPT + end end end diff --git a/test/test_hook_cli.rb b/test/test_hook_cli.rb index a930285..eed9dbc 100644 --- a/test/test_hook_cli.rb +++ b/test/test_hook_cli.rb @@ -63,22 +63,20 @@ def test_user_prompt_submit_reuses_same_thread_for_same_session reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? } assert_equal 1, root_posts.size - assert_equal 2, reply_posts.size - assert reply_posts.all? { |(_text, thread_ts)| thread_ts == '1000.01' } + assert_equal 1, reply_posts.size + assert_equal '1000.01', reply_posts.first.last + assert_includes root_posts.first.first, 'hello' end end - def test_stop_posts_last_assistant_message + def test_stop_posts_last_assistant_message_in_existing_thread with_tmpdir do |dir| ENV['SLACK_BOT_TOKEN'] = 'xoxb-token' ENV['SLACK_CHANNEL'] = 'C123' state_file = dir.join('state.json') - payload = { - 'session_id' => 'session-123', - 'cwd' => '/tmp/app', - 'last_assistant_message' => 'done' - } + prompt_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'prompt' => 'hello' } + stop_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app', 'last_assistant_message' => 'done' } posts = [] original_new = CodexNotify::SlackClient.instance_method(:post) @@ -90,9 +88,16 @@ def test_stop_posts_last_assistant_message end begin + HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'], + stdin: StringIO.new(JSON.generate(prompt_payload)), + stderr: StringIO.new, + stdout: StringIO.new + ) + HookCLI.main( ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'Stop'], - stdin: StringIO.new(JSON.generate(payload)), + stdin: StringIO.new(JSON.generate(stop_payload)), stderr: StringIO.new, stdout: StringIO.new ) @@ -108,6 +113,96 @@ def test_stop_posts_last_assistant_message end end + def test_session_start_does_not_post_root_message + with_tmpdir do |dir| + ENV['SLACK_BOT_TOKEN'] = 'xoxb-token' + ENV['SLACK_CHANNEL'] = 'C123' + + state_file = dir.join('state.json') + payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app' } + + posts = [] + original_new = CodexNotify::SlackClient.instance_method(:post) + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil| + posts << [text, thread_ts] + { 'ok' => true, 'ts' => (thread_ts || '1000.01') } + end + end + + begin + HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'SessionStart'], + stdin: StringIO.new(JSON.generate(payload)), + stderr: StringIO.new, + stdout: StringIO.new + ) + ensure + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post, original_new) + end + end + + assert_empty posts + end + end + + def test_reset_marker_clears_thread_without_posting + with_tmpdir do |dir| + ENV['SLACK_BOT_TOKEN'] = 'xoxb-token' + ENV['SLACK_CHANNEL'] = 'C123' + + state_file = dir.join('state.json') + session_payload = { 'session_id' => 'session-123', 'cwd' => '/tmp/app' } + + posts = [] + original_new = CodexNotify::SlackClient.instance_method(:post) + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post) do |text, thread_ts: nil| + posts << [text, thread_ts] + ts = thread_ts || (posts.count { |(_t, ts_value)| ts_value.nil? } == 1 ? '1000.01' : '2000.01') + { 'ok' => true, 'ts' => ts } + end + end + + begin + HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'], + stdin: StringIO.new(JSON.generate(session_payload.merge('prompt' => 'first prompt'))), + stderr: StringIO.new, + stdout: StringIO.new + ) + + HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'], + stdin: StringIO.new(JSON.generate(session_payload.merge('prompt' => '---'))), + stderr: StringIO.new, + stdout: StringIO.new + ) + + HookCLI.main( + ['--env-file', 'missing.env', '--state-file', state_file.to_s, '--event', 'UserPromptSubmit'], + stdin: StringIO.new(JSON.generate(session_payload.merge('prompt' => 'second prompt'))), + stderr: StringIO.new, + stdout: StringIO.new + ) + ensure + with_silenced_warnings do + CodexNotify::SlackClient.send(:define_method, :post, original_new) + end + end + + root_posts = posts.select { |(_text, thread_ts)| thread_ts.nil? } + reply_posts = posts.select { |(_text, thread_ts)| !thread_ts.nil? } + + assert_equal 2, root_posts.size + assert_equal 0, reply_posts.size + assert_includes root_posts[0].first, 'first prompt' + assert_includes root_posts[1].first, 'second prompt' + refute posts.any? { |(text, _thread_ts)| text.include?('---') } + end + end + private def with_tmpdir