v0.0.4.0 feat: system message on member join + unified payload encoding#116
v0.0.4.0 feat: system message on member join + unified payload encoding#116Fullstop000 merged 8 commits intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds server-authored system messages to channel history when a human/agent joins a channel (via API-driven joins), making joins visible in chat alongside existing realtime member-join events.
Changes:
- Added
Store::join_channel_by_id_with_system_message/join_channel_with_system_messageto atomically join a channel, create a system message, and emit stream events (idempotent when already a member). - Updated multiple server handlers to use the new join-with-notice methods (channel create, invites, team membership, agent auto-join).
- Added a store test to verify join notices for humans/agents and idempotency.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
src/store/channels.rs |
Adds transactional join + system message insertion + event emission helpers. |
src/server/handlers/channels.rs |
Uses the new join-with-system-message path for creator auto-join and invites. |
src/server/handlers/teams.rs |
Uses the new join-with-system-message path when adding team members to the team channel. |
src/server/handlers/agents.rs |
Switches agent auto-join loop to the join-with-system-message helper. |
src/server/handlers/templates.rs |
Switches Launch Trio joins to the join-with-system-message helper. |
tests/store_tests.rs |
Adds coverage for join notice creation and idempotency. |
| // Human joins — creates system message. | ||
| let joined = store | ||
| .join_channel_by_id_with_system_message( | ||
| &channel.id, | ||
| "alice", | ||
| SenderType::Human, | ||
| ) | ||
| .unwrap(); | ||
| assert!(joined, "first join should return true"); | ||
|
|
||
| let (history, _) = store.get_history("general", 10, None, None).unwrap(); | ||
| let sys_msg = history | ||
| .iter() | ||
| .find(|m| m.sender_type == "system") | ||
| .expect("system message should appear in history"); | ||
| assert_eq!(sys_msg.content, "alice joined #general"); | ||
|
|
There was a problem hiding this comment.
Fixed. Added a UUID-id human (human_carol_123) to the test and verified the system message resolves to 'carol joined #general' (the resolved name, not the id).
| .join_channel(&channel_name, &human.name, SenderType::Human); | ||
| let _ = state.store.join_channel_with_system_message( | ||
| &channel_name, | ||
| &human.name, |
There was a problem hiding this comment.
Fixed. Changed human.name to human.id so join_channel resolves the label correctly via SELECT name FROM humans WHERE id = ?1.
| .join_channel(&channel_name, &result.name, SenderType::Agent); | ||
| let _ = state.store.join_channel_with_system_message( | ||
| &channel_name, | ||
| &result.name, |
There was a problem hiding this comment.
Fixed. Changed result.name to result.id so join_channel resolves the label via agents WHERE id = ?1 and stores the correct UUID in channel_members.
| let _ = | ||
| state | ||
| .store | ||
| .join_channel_by_id_with_system_message(&channel.id, &id, SenderType::Agent); | ||
| } |
There was a problem hiding this comment.
Fixed in prior refactor commit 9e10c5c. Removed the store-layer raw INSERT from create_agent_record_inner and moved auto-join into create_agent_record/create_agent_record_in_workspace, which now calls join_channel_by_id. This means agent creation correctly emits the system message for #all on first insert.
When a member joins a channel via API handlers (creation, invite, team assignment), post a server-authored system message into the channel so the join is visible in chat history. Backend: - Added Store::resolve_member_label_tx to resolve human-readable labels (display_name for agents, name for humans) - Added join_channel_by_id_with_system_message and join_channel_with_system_message: atomically insert membership row and create a system message, then emit both member_joined and message.created stream events. Idempotent — returns false and skips the system message when the member is already present. - Updated all runtime API handlers to use the new methods: handle_create_channel, handle_invite_channel_member, handle_create_agent, handle_create_team, handle_add_team_member, handle_launch_trio Tests: - Added test_join_channel_with_system_message_creates_notice_and_is_idempotent verifying human join, agent join with 'Agent' prefix, and idempotency
…of inner helper The function was directly inserting into for the #all channel. This meant that when later called for auto-join channels, the INSERT OR IGNORE returned rows=0 (already a member), so no system message was ever created. Fix: remove the channel_members INSERT from and have / call instead. The connection lock is dropped first to avoid deadlock with the method's own lock acquisition. QA verified: creating an agent now shows 'Agent <name> joined #all' in chat.
…variants to canonical API The old and duplicated the INSERT logic and were only used by tests. The variants were the actual production API but had verbose names. Changes: - Removed old silent / from public API - Renamed → - Renamed → - delegates to after name resolution, eliminating duplication - Added / for unit tests - Added for integration tests - Updated all test files to use silent helpers where they assert on message counts - Fixed test data bugs where was passed by name instead of ID
…an label test - templates.rs: pass human.id and result.id instead of human.name/result.name to join_channel (Copilot review comments #2, #3) - store_tests: add UUID-id human join assertion verifying label resolution (Copilot review comment #1) - agents.rs auto-join path already fixed in prior refactor commit 9e10c5c (Copilot review comment #4)
1497043 to
ec7afce
Compare
Rename `messages.notice` column to `messages.payload` and migrate task
events from JSON-in-content to the same payload column. Two roles, one
column:
- `content` — always-readable English fallback
- `payload` — kind-discriminated JSON (`{kind, audience?, ...}`)
Producers:
- `member_joined` → payload `{kind, audience: "humans", actor, verb, target}`,
content `"alice joined #planning"`
- `task_event` → payload (existing camelCase shape) + English sentence in
content via new `as_human_sentence()` (no `[task]` prefix)
Agent visibility filter is structural — `payload.audience != 'humans'`,
not a kind allowlist. Adding new ambient kinds = set audience humans.
Adding new operational kinds = omit audience (defaults to all). Honors
the project memory rule "no typed event allowlists."
Frontend `Notice/NoticeActor/NoticeTarget` interfaces collapse to a loose
`MessagePayload` (`{kind, [k]: unknown}`); `SystemNotice` and
`parseTaskEvent` narrow at use time. `format_message_for_agent` deleted —
agents read `content` raw now that producers always write it.
No data migration. Existing dev DBs need to be reset on this branch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drop implementation detail in favor of two user-facing bullets. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes #114
When a member joins a channel via API handlers (creation, invite, team assignment, agent creation, launch trio), post a server-authored system message into the channel so the join is visible in chat history.
This PR also reshapes the storage encoding so any structured system message (member-joined chips, task events, future agent_started/task_claimed) shares one column and one filter rule. The original chip rendering and the unification both ship together as a single coherent feature.
Summary
Backend — chip-emitting joins
resolve_member_label_tx: human-readable labels (display_name for agents, name for humans)join_channel_by_id/join_channel: atomically insert membership, post system message, emitchannel.member_joined+message.createdstream eventsOk(false))handle_create_channel,handle_invite_channel_member,handle_create_agent,handle_create_team,handle_add_team_member,handle_launch_trioFrontend —
SystemNoticeprimitiveSystemNotice.tsxrenders any structural[actor chip] [verb] [target chip?]row, centered, all-mono, kicker-stylekind— new kinds work on stale clientsData model — one column, two roles
messages.noticerenamed tomessages.payload(TEXT, nullable JSON)contentis always a human-readable English fallback;payloadis kind-discriminated structured JSONcontentto the samepayloadcolumn.format_message_for_agentdeleted because agents now readcontentrawaudience: "humans"; task events omit audience (defaults to all)Agent visibility filter
payload IS NULLrule replaced with structuralCOALESCE(json_extract(payload, '$.audience'), 'all') != 'humans'Test Coverage
Backend: 528 Rust tests pass (lib + integration). New tests cover human / agent / UUID-id member_joined payload shape, audience-driven agent receive filter, agent history filter, UI history preservation, full task lifecycle (created → claimed → in_review → done) producing English
contentplus structuredpayload.Frontend: 85 vitest tests pass.
parseTaskEventwas retargeted fromcontentJSON parsing topayloadobject narrowing.useTaskEventLog,MessageList,TaskEventMessage, andSystemNoticeall read the new field.Verification (
/gstack-qa)End-to-end browser QA on a fresh DB. All 9 verifications passed, 0 console errors, 0 issues found:
payloadcolumn, nonotice)useAgents()donepill, content is clean English at every stepuseTaskEventLogconsumer)QA report at
.gstack/qa-reports/qa-report-2026-04-28.md.Notes
m.payload, which won't exist on databases predating this PR).format_message_for_agentbridge formatter is gone. Previously it parsed JSON fromcontentto produce an agent sentence. Now producers always write the sentence tocontentdirectly, so the formatter became a passthrough and was deleted.Test plan
cargo test— 528 passingnpm test— 85 passingtsc --noEmit— cleancargo clippy --tests -- -D warnings— cleancargo fmt --check— clean🤖 Generated with Claude Code