Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 36 additions & 19 deletions scripts/check-inbox.sh
Original file line number Diff line number Diff line change
Expand Up @@ -98,25 +98,43 @@ SESSION_ID=$(printf '%s' "$INPUT" \
# Deferral was an optimisation, not a correctness requirement. The read state
# is the correctness requirement, and it was already there.

# Identify agent and teams
WHOAMI=$("$SCRIPT_DIR/whoami.sh" "$PROJECT" "$TYPE")
# suggest=true means this identity is registered only under a DIFFERENT
# project, so it is not joined here -> deliver nothing (mirror not_joined).
# Without this the else-branch extracts "agents=" as the agent name.
if echo "$WHOAMI" | grep -Eq "not_joined=true|suggest=true"; then
exit 0
fi
# Resolve the invocation path to the registered project root (session marker /
# nearest ancestor / sibling worktree) before the identity lookup — the
# whoami.sh path did this resolution, and identities.sh itself is an exact
# registry lookup by design (its other callers rely on that).
PROJECT="$(agmsg_resolve_project "$PROJECT" "$TYPE")"

# Handle multiple identities: use first agent name
if echo "$WHOAMI" | grep -q "multiple=true"; then
AGENT=$(echo "$WHOAMI" | sed -n 's/.*agents=\([^,]*\).*/\1/p')
else
# Anchor on a leading "agent=" so "agents=" (multiple/suggest) cannot match.
AGENT=$(echo "$WHOAMI" | sed -n 's/^agent=\([^ ]*\).*/\1/p')
fi
TEAMS=$(echo "$WHOAMI" | sed -n 's/.*teams=\([^ ]*\).*/\1/p')
# Consume exact (team, agent) TSV rows instead of independently flattened
# agent/team lists. For multiple agents, preserve the existing first-agent
# policy, but subscribe only to that agent's actual team rows.
IDENTITIES=$("$SCRIPT_DIR/identities.sh" "$PROJECT" "$TYPE")
[ -n "$IDENTITIES" ] || exit 0

AGENT=""
TEAM_LIST=()
IDENTITIES_VALID=1
while IFS=$'\t' read -r identity_team identity_agent identity_extra; do
if [ -z "$identity_team" ] || [ -z "$identity_agent" ] || [ -n "$identity_extra" ]; then
IDENTITIES_VALID=0
break
fi

[ -n "$AGENT" ] || AGENT="$identity_agent"
[ "$identity_agent" = "$AGENT" ] || continue

team_seen=0
# ${arr[@]+...} guards the empty-array expansion: under `set -u` bash 3.2
# (macOS default) treats "${TEAM_LIST[@]}" on an empty array as unbound.
for selected_team in ${TEAM_LIST[@]+"${TEAM_LIST[@]}"}; do
if [ "$selected_team" = "$identity_team" ]; then
team_seen=1
break
fi
done
[ "$team_seen" -eq 1 ] || TEAM_LIST+=("$identity_team")
done <<< "$IDENTITIES"

if [ -z "$AGENT" ] || [ -z "$TEAMS" ]; then
if [ "$IDENTITIES_VALID" -ne 1 ] || [ -z "$AGENT" ] || [ "${#TEAM_LIST[@]}" -eq 0 ]; then
exit 0
fi

Expand Down Expand Up @@ -173,7 +191,6 @@ agmsg_storage_load
OUTPUT=""
LOOP_RC=0
LOOP_FAILED_TEAM=""
IFS=',' read -ra TEAM_LIST <<< "$TEAMS"
for team in "${TEAM_LIST[@]}"; do
storage_store_exists "$team" || continue

Expand Down Expand Up @@ -214,7 +231,7 @@ for team in "${TEAM_LIST[@]}"; do
# session, that session owns that role's inbox — don't deliver here.
# Mirrors watch.sh's per-pair filtering (#62).
#
# AGENT comes from whoami.sh: the first registered agent for
# AGENT comes from identities.sh: the first registered agent for
# (project, type), NOT the session's in-memory actas role — the Codex
# caveat documented in README.
state=$(actas_lock_state "$team" "$AGENT" "${SESSION_ID:-}")
Expand Down
44 changes: 44 additions & 0 deletions tests/test_inbox.bats
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@ unread_count() {
' _ "$1" | grep -c .
}

pair_unread_count() {
bash -c '
source "'"$SCRIPTS"'/lib/storage.sh"
agmsg_storage_load
storage_list_unread "$1" "$2"
' _ "$1" "$2" | grep -c .
}

# Wait until the script under test has displayed and is paused before its
# mark UPDATE (barrier .reached appears), with a bounded wait.
await_barrier_reached() {
Expand Down Expand Up @@ -218,6 +226,42 @@ delivered_to_operator() {
[[ "$output" == *"plain=1"* ]]
}

@test "check-inbox: multiple identities poll only the first agent's exact team rows" {
local project="/tmp/exact-pair-project"
bash "$SCRIPTS/join.sh" alpha alice claude-code "$project"
bash "$SCRIPTS/join.sh" beta bob claude-code "$project"

bash "$SCRIPTS/send.sh" alpha system alice "alpha-alice-exact" --force >/dev/null
bash "$SCRIPTS/send.sh" alpha system bob "alpha-bob-cross" --force >/dev/null
bash "$SCRIPTS/send.sh" beta system alice "beta-alice-cross" --force >/dev/null
bash "$SCRIPTS/send.sh" beta system bob "beta-bob-exact" --force >/dev/null

run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code '$project'"
[ "$status" -eq 0 ]
grep -q -F -- 'alpha-alice-exact' <<<"$output"
refute grep -q -F -- 'alpha-bob-cross' <<<"$output"
refute grep -q -F -- 'beta-alice-cross' <<<"$output"
refute grep -q -F -- 'beta-bob-exact' <<<"$output"

[ "$(pair_unread_count alpha alice)" -eq 0 ]
[ "$(pair_unread_count alpha bob)" -eq 1 ]
[ "$(pair_unread_count beta alice)" -eq 1 ]
[ "$(pair_unread_count beta bob)" -eq 1 ]
}

@test "check-inbox: a subdirectory invocation resolves to the registered project root" {
# Registration lives at the root; the Stop hook bakes in whatever path the
# session was started from, so a nested invocation must still find it.
bash "$SCRIPTS/join.sh" gamma carol claude-code /tmp/exact-pair-root

bash "$SCRIPTS/send.sh" gamma system carol "root-resolved" --force >/dev/null

run bash -c "echo '{}' | bash '$SCRIPTS/check-inbox.sh' claude-code /tmp/exact-pair-root/nested/subdir"
[ "$status" -eq 0 ]
grep -q -F -- 'root-resolved' <<<"$output"
[ "$(pair_unread_count gamma carol)" -eq 0 ]
}

@test "check-inbox: a message arriving between display and mark is NOT marked read unseen" {
bash "$SCRIPTS/send.sh" testteam bob alice "early"
AGMSG_TEST_MARK_BARRIER="$BARRIER" bash "$SCRIPTS/check-inbox.sh" claude-code /tmp/project-a \
Expand Down
Loading