Skip to content

feat(projects): per-project A2A channel for agent handover#266

Merged
jaylfc merged 18 commits intomasterfrom
feat/projects-a2a-channel
Apr 27, 2026
Merged

feat(projects): per-project A2A channel for agent handover#266
jaylfc merged 18 commits intomasterfrom
feat/projects-a2a-channel

Conversation

@jaylfc
Copy link
Copy Markdown
Owner

@jaylfc jaylfc commented Apr 26, 2026

Summary

Auto-maintained chat channel per project for agent-to-agent coordination. The channel is a normal chat_channels row (name="a2a", type="group", settings.kind="a2a", project_id set), kept in sync with project_members via three route hooks plus a startup backfill. Frontend default-selects it on first project visit, persists last-selected per project, decorates A2A channels with a Bot icon, and shows an empty-state hint above the composer.

No new database tables, no new API routes, no new chat types — piggybacks on the existing chat-channels infrastructure already shipped with the Projects foundation (#260, #264, #265).

What changed

Backend (tinyagentos/)

  • projects/a2a.py — new invariant module with ensure_a2a_channel (idempotent create + member sync) and backfill_all (startup sweep over active projects)
  • routes/projects.py — try/except hooks in create_project, add_member, remove_member (failure logged, never blocks the route)
  • app.py — startup backfill in lifespan (failure logged, never blocks boot)
  • agent_chat_router.py — bug fix: lowercase the member name when matching against the lowercase mentions.explicit set, so @agentB actually routes to a member named agentB

Frontend (desktop/)

  • MessagesApp.a2aSelection.ts — small helper module (storage key, read/write last-channel, find A2A by settings.kind)
  • MessagesApp.tsx — default-select effect, persist on selection change, Bot icon in 4 sidebar variants, empty-state hint above composer

Test plan

  • Backend: 55/55 tests pass in tests/projects/ (12 new across test_a2a.py, test_routes_a2a.py, test_a2a_handover.py)
  • Frontend: 6 new tests in MessagesApp.a2a-default.test.ts, all pass; tsc --noEmit clean
  • Pre-existing tests/snap-zones.test.ts failures verified to exist on master (unrelated)
  • Smoke test in dev: create new project, confirm A2A channel auto-selected with Bot icon, post @<slug> to verify handover routing

Summary by CodeRabbit

  • New Features

    • Per-project A2A channel selection is persisted and restored across visits
    • Visual A2A indicators (icon + tooltip) added to channel lists and adjusted layout
    • Empty A2A channels show a guidance banner for handing off via @mention
    • App startup backfills and auto-provisions A2A channels; provisioning runs after project/member changes and reconciles members
  • Bug Fixes

    • Agent @mentions are matched case-insensitively for reliable routing
  • Tests

    • New tests cover A2A selection storage, provisioning/backfill, handover routing, and API behaviors

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 482a9cd4-b7ca-4554-b011-702b6b7e4f86

📥 Commits

Reviewing files that changed from the base of the PR and between e8d890b and 0609a24.

📒 Files selected for processing (2)
  • tests/projects/test_a2a.py
  • tinyagentos/projects/a2a.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • tinyagentos/projects/a2a.py
  • tests/projects/test_a2a.py

📝 Walkthrough

Walkthrough

Adds per-project A2A support across frontend and backend: frontend persists/restores last channel per project and shows A2A UI affordances; backend ensures a single A2A group channel per active project (membership reconciliation, duplicate archival, startup backfill); mention routing becomes case-insensitive.

Changes

Cohort / File(s) Summary
Desktop A2A selection & UI
desktop/src/apps/MessagesApp.a2aSelection.ts, desktop/src/apps/MessagesApp.tsx
New localStorage helpers to compute project-scoped key, read/write last channel with error swallowing; selection restored on project load/switch and persisted only when valid; UI: A2A tooltip, Bot icon in channel lists, layout tweaks, and contextual empty-state banner for A2A channels.
Desktop tests
desktop/tests/MessagesApp.a2a-default.test.ts
Unit tests for storage key, read/write behavior (including thrown getItem handling), and findA2aChannelId scanning logic.
Backend A2A core
tinyagentos/projects/a2a.py
New module defining A2A_NAME, A2A_TYPE, A2A_KIND, per-project async locks, _find_a2a_channels, ensure_a2a_channel (create canonical channel, archive duplicates, reconcile members), and backfill_all to sync active projects.
Backend integration & startup
tinyagentos/routes/projects.py, tinyagentos/app.py
Best-effort calls to ensure_a2a_channel after project create/member add/remove with warnings on failure; app startup invokes backfill_all wrapped in try/except and logs result.
Routing behavior
tinyagentos/agent_chat_router.py
Explicit @mention routing now uses case-insensitive matching of candidate agent names against parsed mentions while preserving force-response semantics.
Backend tests / E2E
tests/projects/test_a2a.py, tests/projects/test_a2a_handover.py, tests/projects/test_routes_a2a.py
Async pytest suites covering ensure/backfill idempotency, member sync, duplicate archival, archived-only channel handling, end-to-end @mention handover to agents, route integration (member add/remove, project delete archives A2A), and robustness when ensure_a2a_channel fails.

Sequence Diagram(s)

sequenceDiagram
    participant App as Backend App (lifespan)
    participant PS as ProjectStore
    participant CS as ChatChannelStore

    App->>CS: backfill_all(channel_store, project_store)
    CS->>PS: list active projects
    loop per active project
        CS->>CS: ensure_a2a_channel(project_id)
        CS->>PS: fetch project members
        CS->>CS: find existing A2A channel(s)
        alt canonical exists
            CS->>CS: archive duplicates, pick oldest canonical
            CS->>CS: reconcile members (add/remove)
        else none
            CS->>CS: create A2A group channel with members
        end
        CS-->>App: record success/failure per project
    end
    App->>App: log synced count (or caught error)
Loading
sequenceDiagram
    participant Agent as Authoring Agent
    participant Router as AgentChatRouter
    participant Parser as Mention Parser
    participant Recipient as Recipient Agent/Bridge

    Agent->>Router: send message with "@slug"
    Router->>Parser: parse mentions
    Parser-->>Router: mentions.explicit
    Router->>Router: compare lowercase(candidate names) to mentions
    alt match found
        Router->>Recipient: enqueue_user_message(payload, force_respond=True)
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 I hopped through projects, one by one,
I carved a2a channels when the work begun,
I tucked last picks in bits of storage bright,
Lowercased mentions to guide the flight,
Members synced — now handoffs feel just right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.44% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(projects): per-project A2A channel for agent handover' accurately summarizes the main feature: adding an auto-maintained agent-to-agent (A2A) chat channel per project for agent handover workflows, which is the primary objective across all changed files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/projects-a2a-channel

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
desktop/src/apps/MessagesApp.tsx (1)

1103-1124: A2A marker rendering is duplicated in four list variants.

The same settings.kind === "a2a" tooltip + <Bot> logic is repeated across mobile/desktop/project lists. Consider extracting a tiny helper (or ChannelLabel subcomponent) to keep behavior consistent and reduce drift.

Also applies to: 1177-1198, 1318-1326, 1355-1366

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@desktop/src/apps/MessagesApp.tsx` around lines 1103 - 1124, Duplicate
conditional rendering of the A2A marker (check using ch.settings?.kind ===
"a2a") and the Bot icon appears in multiple list render blocks; extract this
into a small reusable component (e.g., ChannelLabel or A2AMarker) that accepts
the channel object (ch) and selectedChannel/id props and returns the title
attribute and Bot element with the same styles and aria-hidden prop, then
replace the repeated blocks in MessagesApp render locations (the blocks around
the current diff and the ones at lines referenced in the comment) with calls to
that new component to ensure consistent behavior and reduce duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@desktop/src/apps/MessagesApp.tsx`:
- Around line 537-550: The useEffect currently early-returns whenever
selectedChannel is truthy, which preserves stale channel IDs across project
switches; update the selection logic in the useEffect (the function using
readLastChannel, setSelectedChannel, and findA2aChannelId) to only early-return
if selectedChannel exists AND is present in the current channels list (e.g.,
channels.some(c => c.id === selectedChannel)); otherwise treat it as missing and
run the reselection logic (readLastChannel / findA2aChannelId). Also add a guard
where writeLastChannel is called (the code that persists the last channel) to
ensure you only write when the channel being persisted actually belongs to the
current scope.projectId (verify channels contains that id) before calling
writeLastChannel.

In `@tinyagentos/projects/a2a.py`:
- Around line 32-65: ensure_a2a_channel can run concurrently and must be
serialized per project to avoid race conditions during the read-modify-write
sequence around _find_a2a_channel and the channel_store add_member/remove_member
calls; introduce a per-project asyncio.Lock (e.g., a module-level dict mapping
project_id -> asyncio.Lock) and acquire it at the start of ensure_a2a_channel,
perform the existing logic (call _find_a2a_channel, create_channel,
add_member/remove_member, get_channel) while holding the lock, then release it
in a finally block; ensure locks are created lazily and reused for the same
project_id so concurrent invocations for different project_ids remain parallel.

---

Nitpick comments:
In `@desktop/src/apps/MessagesApp.tsx`:
- Around line 1103-1124: Duplicate conditional rendering of the A2A marker
(check using ch.settings?.kind === "a2a") and the Bot icon appears in multiple
list render blocks; extract this into a small reusable component (e.g.,
ChannelLabel or A2AMarker) that accepts the channel object (ch) and
selectedChannel/id props and returns the title attribute and Bot element with
the same styles and aria-hidden prop, then replace the repeated blocks in
MessagesApp render locations (the blocks around the current diff and the ones at
lines referenced in the comment) with calls to that new component to ensure
consistent behavior and reduce duplication.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f80c8ba-d5b1-4e9c-b7ec-e54c7830f600

📥 Commits

Reviewing files that changed from the base of the PR and between c38f7c2 and 040abd4.

📒 Files selected for processing (10)
  • desktop/src/apps/MessagesApp.a2aSelection.ts
  • desktop/src/apps/MessagesApp.tsx
  • desktop/tests/MessagesApp.a2a-default.test.ts
  • tests/projects/test_a2a.py
  • tests/projects/test_a2a_handover.py
  • tests/projects/test_routes_a2a.py
  • tinyagentos/agent_chat_router.py
  • tinyagentos/app.py
  • tinyagentos/projects/a2a.py
  • tinyagentos/routes/projects.py

Comment thread desktop/src/apps/MessagesApp.tsx
Comment thread tinyagentos/projects/a2a.py Outdated
jaylfc added 2 commits April 26, 2026 20:19
Wrap ensure_a2a_channel in a per-project asyncio.Lock so concurrent
callers (e.g. simultaneous add_member / remove_member requests, or a
route firing while backfill is mid-loop) don't race on the
read-modify-write of channel members. Without this, two callers can
each compute a stale member diff and clobber each other's add/remove
operations.

Addresses CodeRabbit review on PR #266.
When the user switches projects, selectedChannel may still hold an id
from the previous project. The default-select effect bailed early on
any truthy selectedChannel, so the new project never got a default
selection — and the persistence call wrote the stale id under the new
project's localStorage key.

Replace the early-return with a selectedStillVisible check against the
current channels list. If the selection is stale, fall back to the
remembered channel for the new project, then to its A2A channel, then
clear. Move persistence into its own effect that only writes when the
selected channel actually belongs to the current project.

Addresses CodeRabbit review on PR #266.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@tinyagentos/projects/a2a.py`:
- Around line 28-37: The helper _find_a2a_channel currently returns the first
match but must enforce "exactly one" A2A channel: list channels via
channel_store.list_channels, filter those where (ch.get("settings") or
{}).get("kind") == A2A_KIND, then if count==0 return None, if count==1 return
that channel, and if count>1 pick a canonical one (e.g., by created/updated
timestamp or just the first), asynchronously delete the other duplicate channels
via channel_store.delete_channel (await each delete), optionally log a warning
about reconciliation, and finally return the chosen canonical channel; apply the
same duplicate-detection/reconciliation logic wherever similar matching is done
(see analogous code around lines 51-53).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0585ff4d-775e-4178-9ea7-98231ea29f4b

📥 Commits

Reviewing files that changed from the base of the PR and between 040abd4 and e93c379.

📒 Files selected for processing (2)
  • desktop/src/apps/MessagesApp.tsx
  • tinyagentos/projects/a2a.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • desktop/src/apps/MessagesApp.tsx

Comment thread tinyagentos/projects/a2a.py Outdated
ensure_a2a_channel previously took the first match and ignored the rest,
silently tolerating duplicate A2A channels for a single project. Even with
the per-project lock added in 10a30a4, pre-lock data, manual DB tampering,
or future migrations could produce duplicates — and once they exist nothing
heals them.

Switch _find_a2a_channel to _find_a2a_channels (returns oldest-first list).
Treat the oldest as canonical, archive any extras with set_settings, and
log a warning. Added test covering the reconciliation path.

Addresses CodeRabbit review on PR #266.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
tinyagentos/projects/a2a.py (1)

35-39: ⚠️ Potential issue | 🟠 Major

Match only active, fully-identified A2A channels in candidate lookup.

Line 35 currently loads all project channels (including archived), and Lines 37-39 match only settings.kind. That can select an archived or mis-tagged non-name="a2a"/type="group" channel as canonical, breaking the active-channel invariant.

🔧 Suggested fix
 async def _find_a2a_channels(channel_store, project_id: str) -> list[dict]:
@@
-    channels = await channel_store.list_channels(project_id=project_id)
+    channels = await channel_store.list_channels(
+        project_id=project_id,
+        archived=False,
+    )
     return [
-        ch for ch in channels
-        if (ch.get("settings") or {}).get("kind") == A2A_KIND
+        ch
+        for ch in channels
+        if ch.get("name") == A2A_NAME
+        and ch.get("type") == A2A_TYPE
+        and (ch.get("settings") or {}).get("kind") == A2A_KIND
     ]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tinyagentos/projects/a2a.py` around lines 35 - 39, The candidate lookup
currently returns any channel whose settings.kind == A2A_KIND (via
channel_store.list_channels), which can include archived or mis-tagged channels;
update the filter to only return active, fully-identified A2A channels by
checking that (ch.get("settings") or {}).get("kind") == A2A_KIND AND the channel
is not archived (e.g., not ch.get("archived")) AND the channel has the canonical
identifiers (e.g., ch.get("name") == "a2a" and ch.get("type") == "group"); apply
this change where channels are filtered after channel_store.list_channels so
only active, correctly-named/group channels are returned.
🧹 Nitpick comments (1)
tests/projects/test_a2a.py (1)

129-155: Add a regression test for archived-only A2A rows.

You already test duplicate archival; add a case where the only existing A2A row is archived and ensure_a2a_channel must provision/select a new active one.

🧪 Suggested test addition
 `@pytest.mark.asyncio`
 async def test_ensure_archives_duplicate_a2a_channels(stores):
@@
     assert len(a2a_active) == 1
     assert a2a_active[0]["id"] == canonical["id"]
+
+
+@pytest.mark.asyncio
+async def test_ensure_ignores_archived_a2a_and_creates_active(stores):
+    project_store, channel_store = stores
+    p = await project_store.create_project(name="P", slug="archived-only", created_by="u1")
+
+    old = await ensure_a2a_channel(channel_store, project_store, p["id"])
+    await channel_store.set_settings(old["id"], {"archived": True})
+
+    current = await ensure_a2a_channel(channel_store, project_store, p["id"])
+    assert current["id"] != old["id"]
+    assert (current.get("settings") or {}).get("archived") is not True
+
+    active = await channel_store.list_channels(project_id=p["id"], archived=False)
+    a2a_active = [c for c in active if (c.get("settings") or {}).get("kind") == A2A_KIND]
+    assert len(a2a_active) == 1
+    assert a2a_active[0]["id"] == current["id"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/projects/test_a2a.py` around lines 129 - 155, Add a new async test
(similar to test_ensure_archives_duplicate_a2a_channels) that creates a project,
inserts an A2A channel and then marks that channel archived (set
settings.archived = True) before calling ensure_a2a_channel(project_id). Verify
that ensure_a2a_channel returns a different (new) active channel id than the
archived one, that the archived channel remains archived, and that exactly one
active channel with settings.kind == "a2a" exists; use the same helpers and
symbols as in the file (ensure_a2a_channel, channel_store.create_channel,
channel_store.get_channel, channel_store.list_channels, A2A_NAME, A2A_TYPE,
A2A_KIND) to implement the assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tinyagentos/projects/a2a.py`:
- Around line 35-39: The candidate lookup currently returns any channel whose
settings.kind == A2A_KIND (via channel_store.list_channels), which can include
archived or mis-tagged channels; update the filter to only return active,
fully-identified A2A channels by checking that (ch.get("settings") or
{}).get("kind") == A2A_KIND AND the channel is not archived (e.g., not
ch.get("archived")) AND the channel has the canonical identifiers (e.g.,
ch.get("name") == "a2a" and ch.get("type") == "group"); apply this change where
channels are filtered after channel_store.list_channels so only active,
correctly-named/group channels are returned.

---

Nitpick comments:
In `@tests/projects/test_a2a.py`:
- Around line 129-155: Add a new async test (similar to
test_ensure_archives_duplicate_a2a_channels) that creates a project, inserts an
A2A channel and then marks that channel archived (set settings.archived = True)
before calling ensure_a2a_channel(project_id). Verify that ensure_a2a_channel
returns a different (new) active channel id than the archived one, that the
archived channel remains archived, and that exactly one active channel with
settings.kind == "a2a" exists; use the same helpers and symbols as in the file
(ensure_a2a_channel, channel_store.create_channel, channel_store.get_channel,
channel_store.list_channels, A2A_NAME, A2A_TYPE, A2A_KIND) to implement the
assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 43369f8a-792d-4f7b-9b29-4128115587e5

📥 Commits

Reviewing files that changed from the base of the PR and between e93c379 and e8d890b.

📒 Files selected for processing (2)
  • tests/projects/test_a2a.py
  • tinyagentos/projects/a2a.py

_find_a2a_channels was matching only on settings.kind, which had two holes:

1. After reconciliation archives a duplicate, the archived row would be
   returned again on the next ensure call. The oldest entry remains
   canonical so the immediate behavior was fine, but if the canonical row
   itself ever gets archived (admin action, future migration), the helper
   would re-elect it — breaking routing for a project whose A2A channel
   is supposed to be active.
2. A mis-tagged channel with kind=a2a but a different name or type
   could be selected as canonical.

Filter list_channels with archived=False and require name=A2A_NAME,
type=A2A_TYPE, settings.kind=A2A_KIND. Added a regression test covering
the "only archived A2A exists → provision a fresh one" path.

Addresses CodeRabbit follow-up review on PR #266.
@jaylfc jaylfc merged commit d0b2a5b into master Apr 27, 2026
8 checks passed
@jaylfc jaylfc deleted the feat/projects-a2a-channel branch April 27, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant