Skip to content

v0.0.4.0 feat: system message on member join + unified payload encoding#116

Merged
Fullstop000 merged 8 commits intomainfrom
feat/system-message-on-member-join
Apr 28, 2026
Merged

v0.0.4.0 feat: system message on member join + unified payload encoding#116
Fullstop000 merged 8 commits intomainfrom
feat/system-message-on-member-join

Conversation

@Fullstop000
Copy link
Copy Markdown
Owner

@Fullstop000 Fullstop000 commented Apr 27, 2026

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, emit channel.member_joined + message.created stream events
  • Idempotent — re-join is a noop (Ok(false))
  • All API handlers route through the new methods: handle_create_channel, handle_invite_channel_member, handle_create_agent, handle_create_team, handle_add_team_member, handle_launch_trio

Frontend — SystemNotice primitive

  • New SystemNotice.tsx renders any structural [actor chip] [verb] [target chip?] row, centered, all-mono, kicker-style
  • Falls back to plain hairline divider when payload is missing or actor lookup fails
  • Routes clicks: agent chip → profile, human chip → name span (no profile route yet), channel chip → switch channel
  • Renderer never switches on kind — new kinds work on stale clients

Data model — one column, two roles

  • messages.notice renamed to messages.payload (TEXT, nullable JSON)
  • content is always a human-readable English fallback; payload is kind-discriminated structured JSON
  • Task events migrate from JSON-in-content to the same payload column. format_message_for_agent deleted because agents now read content raw
  • Member-joined payloads carry audience: "humans"; task events omit audience (defaults to all)

Agent visibility filter

  • payload IS NULL rule replaced with structural COALESCE(json_extract(payload, '$.audience'), 'all') != 'humans'
  • Adding new ambient kinds = set audience humans. Adding new operational kinds = omit audience. No kind allowlist anywhere — honors the project's "no typed event allowlists" rule.

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 content plus structured payload.

Frontend: 85 vitest tests pass. parseTaskEvent was retargeted from content JSON parsing to payload object narrowing. useTaskEventLog, MessageList, TaskEventMessage, and SystemNotice all read the new field.

cargo test               → 528 passing
cargo clippy --tests     → clean
cargo fmt --check        → clean
npm test                 → 85 passing
tsc --noEmit             → clean

Verification (/gstack-qa)

End-to-end browser QA on a fresh DB. All 9 verifications passed, 0 console errors, 0 issues found:

  1. Schema renamed cleanly (payload column, no notice)
  2. Human member-joined chip renders structurally with clickable target
  3. Agent member-joined chip renders, resolves live display name via useAgents()
  4. Task event card renders rich (no raw JSON visible anywhere)
  5. Task lifecycle (created → claimed → in_review → done) — card collapses to done pill, content is clean English at every step
  6. Agent audience filter excludes humans-only payloads, task events flow through
  7. UI history returns everything for humans (chips + cards)
  8. Task detail page works (downstream useTaskEventLog consumer)
  9. Zero console errors throughout

QA report at .gstack/qa-reports/qa-report-2026-04-28.md.

Notes

  • No data migration shipped. Existing dev DBs on this branch must be wiped (the schema's view recreate references m.payload, which won't exist on databases predating this PR).
  • The format_message_for_agent bridge formatter is gone. Previously it parsed JSON from content to produce an agent sentence. Now producers always write the sentence to content directly, so the formatter became a passthrough and was deleted.

Test plan

  • cargo test — 528 passing
  • npm test — 85 passing
  • tsc --noEmit — clean
  • cargo clippy --tests -- -D warnings — clean
  • cargo fmt --check — clean
  • Browser QA — chips, task cards, lifecycle, audience filter all verified

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings April 27, 2026 10:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_message to 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.

Comment thread tests/store_tests.rs
Comment on lines +2214 to +2230
// 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");

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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).

Comment thread src/server/handlers/templates.rs Outdated
.join_channel(&channel_name, &human.name, SenderType::Human);
let _ = state.store.join_channel_with_system_message(
&channel_name,
&human.name,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixed. Changed human.name to human.id so join_channel resolves the label correctly via SELECT name FROM humans WHERE id = ?1.

Comment thread src/server/handlers/templates.rs Outdated
.join_channel(&channel_name, &result.name, SenderType::Agent);
let _ = state.store.join_channel_with_system_message(
&channel_name,
&result.name,
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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.

Comment thread src/server/handlers/agents.rs Outdated
Comment on lines 366 to 370
let _ =
state
.store
.join_channel_by_id_with_system_message(&channel.id, &id, SenderType::Agent);
}
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

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)
@Fullstop000 Fullstop000 force-pushed the feat/system-message-on-member-join branch from 1497043 to ec7afce Compare April 27, 2026 15:39
Fullstop000 and others added 2 commits April 28, 2026 12:18
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>
@Fullstop000 Fullstop000 changed the title feat: system message when member joins a channel (#114) v0.0.4.0 feat: system message on member join + unified payload encoding Apr 28, 2026
Drop implementation detail in favor of two user-facing bullets.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Fullstop000 Fullstop000 merged commit caf2b21 into main Apr 28, 2026
3 checks passed
@Fullstop000 Fullstop000 deleted the feat/system-message-on-member-join branch April 28, 2026 07:32
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.

Add system message when member joins a channel

2 participants