Skip to content

Commit ec8e51f

Browse files
authored
Merge pull request #41 from jw-12138/ux/codex-dup-notification
fix: suppress duplicate Codex notifications
2 parents 1ea2e2c + 567b8ed commit ec8e51f

2 files changed

Lines changed: 160 additions & 5 deletions

File tree

lib/code-notify/core/notifier.sh

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,92 @@ is_project_scoped_notification() {
217217
return 1
218218
}
219219

220+
# Find the newest Codex state database without hard-coding a schema version suffix.
221+
get_latest_codex_state_db() {
222+
local latest=""
223+
local candidate
224+
225+
for candidate in "$HOME/.codex"/state*.sqlite; do
226+
[[ -e "$candidate" ]] || continue
227+
if [[ -z "$latest" ]] || [[ "$candidate" -nt "$latest" ]]; then
228+
latest="$candidate"
229+
fi
230+
done
231+
232+
[[ -n "$latest" ]] || return 1
233+
printf '%s\n' "$latest"
234+
}
235+
236+
# Resolve the thread originator from Codex local state when the notify payload includes thread-id.
237+
get_codex_thread_originator() {
238+
local thread_id="$1"
239+
local state_db
240+
241+
[[ -n "$thread_id" ]] || return 1
242+
has_python3 || return 1
243+
244+
state_db=$(get_latest_codex_state_db) || return 1
245+
246+
python3 - "$state_db" "$thread_id" <<'PY' 2>/dev/null
247+
import json
248+
import pathlib
249+
import sqlite3
250+
import sys
251+
252+
db_path = pathlib.Path(sys.argv[1])
253+
thread_id = sys.argv[2]
254+
255+
try:
256+
with sqlite3.connect(db_path) as conn:
257+
cur = conn.cursor()
258+
cur.execute("select rollout_path from threads where id = ?", (thread_id,))
259+
row = cur.fetchone()
260+
except Exception:
261+
row = None
262+
263+
if not row or not row[0]:
264+
raise SystemExit(0)
265+
266+
try:
267+
first_line = pathlib.Path(row[0]).read_text(encoding="utf-8", errors="ignore").splitlines()[0]
268+
payload = json.loads(first_line).get("payload", {})
269+
originator = payload.get("originator", "")
270+
except Exception:
271+
originator = ""
272+
273+
if isinstance(originator, str):
274+
print(originator, end="")
275+
PY
276+
}
277+
278+
# Suppress only when this Codex event came from the desktop app itself.
279+
# Set CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK=1 to disable (used in tests).
280+
is_codex_desktop_trigger() {
281+
[[ "$TOOL_NAME" != "codex" ]] && return 1
282+
[[ "${CODE_NOTIFY_SKIP_CODEX_DESKTOP_CHECK:-}" == "1" ]] && return 1
283+
284+
local client
285+
client=$(json_extract_string "$HOOK_DATA" "client" | tr '[:upper:]' '[:lower:]')
286+
case "$client" in
287+
*app*|appserver)
288+
return 0
289+
;;
290+
esac
291+
292+
local thread_id originator
293+
thread_id=$(json_extract_string "$HOOK_DATA" "thread-id")
294+
[[ -n "$thread_id" ]] || return 1
295+
296+
originator=$(get_codex_thread_originator "$thread_id")
297+
case "$originator" in
298+
"Codex Desktop")
299+
return 0
300+
;;
301+
esac
302+
303+
return 1
304+
}
305+
220306
# Function to check if notification should be suppressed
221307
should_suppress_notification() {
222308
# Check kill switch first - instant disable without restart
@@ -229,6 +315,11 @@ should_suppress_notification() {
229315
return 1
230316
fi
231317

318+
# Suppress only when this Codex event originated from the desktop app.
319+
if is_codex_desktop_trigger; then
320+
return 0
321+
fi
322+
232323
# Rate limit stop notifications to prevent spam from parallel sub-agents
233324
if [[ "$HOOK_TYPE" == "stop" ]]; then
234325
if is_rate_limited "last_stop_notification" "$STOP_RATE_LIMIT_SECONDS"; then

tests/test-codex-notify.sh

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,72 @@ run_codex_notifier() {
2727
local payload="$2"
2828

2929
PATH="$fake_path" \
30+
CODE_NOTIFY_STOP_RATE_LIMIT_SECONDS=0 \
3031
CODE_NOTIFY_NOTIFICATION_RATE_LIMIT_SECONDS=180 \
3132
bash "$NOTIFIER" codex "$payload"
3233
}
3334

35+
write_codex_thread_metadata() {
36+
local thread_id="$1"
37+
local originator="$2"
38+
local source="${3:-vscode}"
39+
40+
python3 - "$HOME/.codex/state_5.sqlite" "$HOME/.codex/sessions" "$thread_id" "$originator" "$source" <<'PY'
41+
import json
42+
import pathlib
43+
import sqlite3
44+
import sys
45+
46+
db_path = pathlib.Path(sys.argv[1])
47+
sessions_dir = pathlib.Path(sys.argv[2])
48+
thread_id = sys.argv[3]
49+
originator = sys.argv[4]
50+
source = sys.argv[5]
51+
52+
rollout_path = sessions_dir / f"{thread_id}.jsonl"
53+
rollout_path.parent.mkdir(parents=True, exist_ok=True)
54+
rollout_path.write_text(
55+
json.dumps(
56+
{
57+
"type": "session_meta",
58+
"payload": {
59+
"id": thread_id,
60+
"originator": originator,
61+
"source": source,
62+
},
63+
}
64+
)
65+
+ "\n",
66+
encoding="utf-8",
67+
)
68+
69+
with sqlite3.connect(db_path) as conn:
70+
cur = conn.cursor()
71+
cur.execute(
72+
"""
73+
create table if not exists threads (
74+
id text primary key,
75+
source text,
76+
rollout_path text
77+
)
78+
"""
79+
)
80+
cur.execute(
81+
"insert or replace into threads (id, source, rollout_path) values (?, ?, ?)",
82+
(thread_id, source, str(rollout_path)),
83+
)
84+
conn.commit()
85+
PY
86+
}
87+
3488
test_dir="$(mktemp -d)"
3589
trap 'rm -rf "$test_dir"' EXIT
3690

3791
export HOME="$test_dir/home"
3892
fake_bin="$test_dir/bin"
3993
log_dir="$test_dir/log"
4094
sound_file="$test_dir/custom.aiff"
41-
mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$fake_bin" "$log_dir"
95+
mkdir -p "$HOME/.claude/notifications" "$HOME/.claude/logs" "$HOME/.codex" "$fake_bin" "$log_dir"
4296

4397
touch "$sound_file"
4498
: > "$HOME/.claude/notifications/sound-enabled"
@@ -81,12 +135,22 @@ fake_path="$fake_bin:/usr/bin:/bin:/usr/sbin:/sbin"
81135

82136
run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","cwd":"/tmp/demo","client":"codex-exec","input-messages":["Run tests"],"last-assistant-message":"All tests passed"}'
83137
run_codex_notifier "$fake_path" '{"type":"request_permissions","cwd":"/tmp/demo","tool":"exec_command"}'
138+
run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","cwd":"/tmp/demo","client":"codex-app","last-assistant-message":"Desktop event"}'
139+
140+
write_codex_thread_metadata "desktop-thread" "Codex Desktop"
141+
run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"desktop-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"Desktop-backed event"}'
142+
143+
write_codex_thread_metadata "cli-thread" "Codex CLI" "shell"
144+
run_codex_notifier "$fake_path" '{"type":"agent-turn-complete","thread-id":"cli-thread","cwd":"/tmp/demo","client":"codex-exec","last-assistant-message":"CLI event still notifies"}'
84145

85-
wait_for_lines "$notification_log" 2 || fail "expected two Codex notification deliveries"
86-
wait_for_lines "$sound_log" 2 || fail "expected two Codex sound playbacks"
87-
wait_for_lines "$HOME/.claude/logs/notifications.log" 2 || fail "expected two Codex notification log entries"
146+
wait_for_lines "$notification_log" 3 || fail "expected three Codex notification deliveries"
147+
wait_for_lines "$sound_log" 3 || fail "expected three Codex sound playbacks"
148+
wait_for_lines "$HOME/.claude/logs/notifications.log" 3 || fail "expected three Codex notification log entries"
88149

89150
grep -q "Task Complete - demo" "$notification_log" || fail "Codex completion payload did not map to a stop notification"
90151
grep -q "Input Required - demo" "$notification_log" || fail "Codex permission-like payload did not map to an input-required notification"
152+
[[ $(wc -l < "$notification_log") -eq 3 ]] || fail "desktop-origin Codex events were not suppressed correctly"
153+
[[ $(wc -l < "$sound_log") -eq 3 ]] || fail "desktop-origin Codex sound playback was not suppressed correctly"
154+
[[ $(wc -l < "$HOME/.claude/logs/notifications.log") -eq 3 ]] || fail "desktop-origin Codex log entries were not suppressed correctly"
91155

92-
pass "Codex payload parsing maps completion and permission-like payloads to the expected notification types"
156+
pass "Codex notifies for CLI sessions while suppressing desktop-origin duplicate events"

0 commit comments

Comments
 (0)