feat(projects): per-project A2A channel for agent handover#266
feat(projects): per-project A2A channel for agent handover#266
Conversation
parse_mentions returns lowercase explicit mentions and filters against a lowercase canonical member set. The router was comparing original-case member names against that lowercase set, so @agentB never matched a member literally named agentB. Lowercase the member name on the comparison to match the canonical form parse_mentions produces.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
🚧 Files skipped from review as they are similar to previous changes (2)
📝 WalkthroughWalkthroughAdds 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
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)
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 (orChannelLabelsubcomponent) 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
📒 Files selected for processing (10)
desktop/src/apps/MessagesApp.a2aSelection.tsdesktop/src/apps/MessagesApp.tsxdesktop/tests/MessagesApp.a2a-default.test.tstests/projects/test_a2a.pytests/projects/test_a2a_handover.pytests/projects/test_routes_a2a.pytinyagentos/agent_chat_router.pytinyagentos/app.pytinyagentos/projects/a2a.pytinyagentos/routes/projects.py
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.
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
desktop/src/apps/MessagesApp.tsxtinyagentos/projects/a2a.py
🚧 Files skipped from review as they are similar to previous changes (1)
- desktop/src/apps/MessagesApp.tsx
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.
There was a problem hiding this comment.
♻️ Duplicate comments (1)
tinyagentos/projects/a2a.py (1)
35-39:⚠️ Potential issue | 🟠 MajorMatch 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_channelmust 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
📒 Files selected for processing (2)
tests/projects/test_a2a.pytinyagentos/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.
Summary
Auto-maintained chat channel per project for agent-to-agent coordination. The channel is a normal
chat_channelsrow (name="a2a",type="group",settings.kind="a2a",project_idset), kept in sync withproject_membersvia 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 withensure_a2a_channel(idempotent create + member sync) andbackfill_all(startup sweep over active projects)routes/projects.py— try/except hooks increate_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 lowercasementions.explicitset, so@agentBactually routes to a member namedagentBFrontend (
desktop/)MessagesApp.a2aSelection.ts— small helper module (storage key, read/write last-channel, find A2A bysettings.kind)MessagesApp.tsx— default-select effect, persist on selection change, Bot icon in 4 sidebar variants, empty-state hint above composerTest plan
tests/projects/(12 new acrosstest_a2a.py,test_routes_a2a.py,test_a2a_handover.py)MessagesApp.a2a-default.test.ts, all pass;tsc --noEmitcleantests/snap-zones.test.tsfailures verified to exist on master (unrelated)@<slug>to verify handover routingSummary by CodeRabbit
New Features
Bug Fixes
Tests