feat: rewrite Chorus daemon as fully local Rust application#1
Merged
Fullstop000 merged 36 commits intomainfrom Mar 20, 2026
Merged
feat: rewrite Chorus daemon as fully local Rust application#1Fullstop000 merged 36 commits intomainfrom
Fullstop000 merged 36 commits intomainfrom
Conversation
Port of the TypeScript ClaudeDriver. Spawns `claude` CLI with stream-json I/O, writes MCP config to .chorus-mcp.json, parses NDJSON events from stdout, and supports stdin notification for mid-turn message delivery.
Port of the TypeScript CodexDriver. Spawns `codex exec` with --json, initializes git repo if needed, configures MCP chat bridge via -c args, and parses the Codex event stream (thread/turn/item events).
Port of agentProcessManager.ts to Rust. Manages agent child processes including spawning CLI processes via Driver implementations, reading stdout events, lifecycle management (start/stop/sleep), workspace setup with MEMORY.md, and stdin notification delivery for new messages.
Replaces the bridge.rs stub with a full implementation of the MCP server that proxies chat operations to the local HTTP API. Uses rmcp crate macros with Parameters<T> pattern for tool definitions. Tools: send_message, receive_message, read_history, list_server, list_tasks, create_tasks, claim_tasks, unclaim_task, update_task_status, upload_file, view_file.
Signed-off-by: Fullstop000 <fullstop1005@gmail.com>
15f7118 to
05b36b2
Compare
Add AppProvider/useApp/useTarget store with polling server info, channel and agent selection logic. Update App.tsx to use the new layout shell. Add App.css design tokens. Add placeholder Sidebar and MainPanel stubs.
- Add GET /api/agents/{name}/activity endpoint returning messages sent by agent across all channels
- Add GET /api/agents/{name}/workspace endpoint listing files in ~/.chorus/{agent}/
- Add get_agent_activity() store method with cross-channel SQL query
- Create ActivityPanel component with polling, channel badges, timestamps
- Create WorkspacePanel component with file-type icons and tree indentation
- Wire both panels into MainPanel replacing "coming soon" placeholder
…/ not ~/.chorus/{name}/
Fullstop000
added a commit
that referenced
this pull request
Apr 3, 2026
…ipeline Before: applyRealtimeEvent was a 40-line monolith mixing validation, field mapping (WS→cache), routing (thread vs channel), and dedup/ merge logic in one switch statement. This caused Bug #1 (thread replies leaking into channel list) and Bug #2 (double-render) because the function had no context about WHO was calling it. After: three focused functions with clear single responsibilities: - normalizeEvent(event): validate + map WS payload → HistoryMessage | null (ONE place for all field mapping, returns null for invalid/ignored) - upsertMessage(list, msg): pure dedup-by-ID-or-nonce + Map merge + sort (no routing, no field mapping, just set semantics) - bumpReplyCount(list, parentId): increment replyCount on parent message (thread-specific side-effect, explicitly called by caller) Routing lives in useHistory.onFrame where isThreadView context is known: if (msg.thread_parent_id && !isThreadView) → bumpReplyCount only else → upsertMessage Tests: 12 unit tests (normalize, upsert, bumpReplyCount), 7/7 QA pass, 45/45 Rust tests pass.
Fullstop000
added a commit
that referenced
this pull request
Apr 6, 2026
* refactor(ui): modular components, pages shell, agents subdirs
- Organize feature UI under components/{chat,channels,agents,tasks}
- Nest agents/profile and agents/activity under agents
- Move Sidebar, MainPanel, TabBar to pages/
- Remove unused prototype and demo components
- Normalize channels directory to lowercase for cross-platform paths
- Update AGENTS.md project layout
Made-with: Cursor
* refactor(ui): module layout, split types, colocate Sidebar
- Organize src: api/, store/, types barrel, inbox/, lib (utils+queryClient)
- Feature components under chat/, channels/, agents/ (profile+activity), tasks/
- Pages: MainPanel, TabBar; Sidebar/ with sidebarChannels filter
- Domain types in colocated types.ts; types/index re-exports for api/store
- transport/types for RealtimeMessage; lowercase channels/ in git
- Remove prototype/demo components; update AGENTS.md
Made-with: Cursor
* feat(data): create data layer with domain modules, query definitions, and transforms
- Add ui/src/data/ as single source for API calls, types, TanStack Query
options, and post-fetch transforms per domain (channels, agents, tasks,
teams, chat, inbox)
- Each domain module co-locates model types + CRUD functions +
queryKeys/queryOptions (backend-style structure)
- Move TanStack Query keys/options from store/queryKeys.ts into
respective data modules; rename *QueryOptions → *Query
- Convert inbox bootstrap from manual 3-file gate to self-gating
derived query (inboxQuery depends on channelsData via enabled+select)
- Convert useHistory from raw useState/useEffect to useQuery-based
hook; delete historyRequestCache.ts (loadSharedRequest replaced by
built-in request dedup)
- Delete api/index.ts shim; all consumers now import from data/
- Refactor store layer: split monolithic store/index.ts into focused
files (useAppDataQueries, useAppRefreshActions,
useInboxRealtimeSubscription, useShellLifecycle)
- Fix duplicate sliceChannels() in useAppDataQueries — now imported
from data/channels
- Flip all cross-module type imports (store, hooks, components) to
use data/ as single source
- tsc clean, 26 tests pass
* refactor(ui): inline store utilities, centralize request types, fix bugs
- Inline 4 single-consumer store hooks into App.tsx and hooks/data.ts:
useAppDataQueries, useAppInboxSelectors, useShellLifecycle,
useInboxRealtimeSubscription (all deleted)
- Create ui/src/data/requests.ts with 17 centralized request type
definitions; update all data modules to import from it
- Fix pre-existing bugs: missing QueryClientProvider in main.tsx,
setState-during-render in inbox bootstrap, invite payload shape
(raw string → {memberName} object), camelCase→snake_case channel
query params
- Add system channel guard to canInviteMembers in MainPanel
- Reset showMembersPanel/showTeamSettings on channelId change to
prevent stale state leak across channel switches
- Fix CHN-003 test: close members panel after #all steps
- Improve waitForAppReady and openMembersPanel helpers
* refactor(ui): extract queryClient into dedicated lib/queryClient.ts
Keeps lib/utils.ts as a pure helper module (only cn()) so that
components importing cn do not pull in @tanstack/react-query
or instantiate QueryClient as a side effect.
* refactor(ui): rename lib/utils.ts → lib/cn.ts for clarity
The file only contains the cn() Tailwind class helper. A dedicated
filename makes its single purpose obvious and avoids implying it is
a general-purpose utility bucket.
* docs(store): add JSDoc comments to UIState and UIActions fields
* refactor(ui): inline useAppRefreshActions into hooks/data.ts and delete
Single-consumer hook (only hooks/data.ts used it after earlier inlining).
The 4 trivial query invalidators and the complex thread-refresh
dedup logic now live directly in useRefresh().
* refactor(ui): update shadcn primitives import cn from lib/cn
* fix(chat): render non-thread realtime messages in DM and channel chat
applyRealtimeEvent silently dropped all message.created events that were
not thread replies (threadParentId was null/empty). For regular DM and
channel messages, the function returned the messages list unchanged,
so incoming messages never appeared in the UI.
Now constructs a HistoryMessage from the event payload and appends it
for non-thread messages. Thread replies continue to increment replyCount
on the parent message as before.
Add DM-001 smoke test: opens agent DM, sends message, verifies it renders.
* fix(chat): read nested sender object from realtime event payload
Backend sends sender as a nested { name, type } object in the WebSocket
message.created payload (see store/messages/types.rs
to_transport_payload). The previous fix read flat p.senderName /
p.senderType which are always undefined for incoming messages, causing
the guard (!senderName) to silently drop every agent/bot reply.
Now reads both shapes: p.sender.name/type (nested) with fallback to
p.senderName/p.senderType (flat) for forward compat.
* fix(chat): refetch history on minimal realtime notification stubs
Backend sends message.created events as minimal stubs (only messageId,
conversationId, threadParentId) — no content or sender fields — for ALL
messages (see posting.rs event_payload construction). This is by design:
the event is a notification that triggers a refetch.
Previously applyRealtimeEvent tried (and failed) to construct a
HistoryMessage from the incomplete payload, then silently returned
messages unchanged with no fallback. Now:
- applyRealtimeEvent only handles thread reply count increments
- useHistory detects when optimistic append didn't happen
- Falls through: incremental getHistoryAfter if seq advanced,
else full refetch as safety net
- This guarantees incoming agent/bot messages render in DM chat
* fix(chat): use messageCountChanged boolean instead of setQueryData return value
queryClient.setQueryData() returns the data object (truthy when cache
exists), NOT a boolean indicating whether the updater changed anything.
This meant didOptimisticAppend was always truthy after initial load,
causing all refetch fallback paths to be silently skipped.
Root cause of DM messages not rendering: every incoming agent reply
event hit this early return and never triggered the incremental/full
refetch that would fetch the actual message content from the API.
Verified with debug trace: event received → NOOP → incrementalAfter=9
→ getHistoryAfter → 1 new message → .message-item count 9→10 ✅
* feat: include full message content in WebSocket event payload
Backend change: Replace minimal stub payload (only messageId, conversationId,
threadParentId) with full transport payload including sender, content,
createdAt, seq in all 3 message creation paths:
- create_message_with_forwarded_from (user messages)
- create_message (thread replies)
- create_system_message (agent/system messages)
Added InsertedMessage::to_transport_payload() helper that builds the
complete JSON payload matching the shape of ConversationMessageView's
to_transport_payload.
Frontend change: applyRealtimeEvent now constructs and appends a
HistoryMessage from the full payload for non-thread messages, enabling
instant rendering without an API refetch round-trip. This fixes DM chat
messages not appearing.
Also fixed useHistory: use messageCountChanged boolean instead of
relying on setQueryData() return value (which returns data object,
not boolean) to correctly detect when optimistic append didn't happen
and fallback to incremental/full refetch.
* refactor: typed MessageCreatedPayload struct (backend + frontend)
Replace dynamic json!() payload construction with proper serde structs:
Backend (types.rs + posting.rs):
- Add MessageCreatedPayload struct with snake_case serde fields
(messageId, conversationId, sender { name, type }, content, seq, etc.)
- Add MessageSenderInfo nested struct with rename='type' for the
'type' Rust keyword field
- InsertedMessage::to_event_payload() returns the struct instead of
Value; posting.rs serializes via serde_json::to_value()
- Removes unused serde_json::json import
Frontend (chat.ts + realtime.ts):
- Add MessageCreatedPayload and MessageSenderInfo TypeScript interfaces
matching the Rust struct shape exactly
- StreamEvent.payload is now Record<string, unknown> &
Partial<MessageCreatedPayload> for typed access on message.created events
- applyRealtimeEvent simplified: p.messageId, p.sender.name, etc.
with ?? fallbacks instead of typeof guards everywhere
* fix: serde rename_all camelCase on MessageCreatedPayload
Rust's default serde serialization is snake_case (message_id,
conversation_id, etc.) but the TypeScript interface expects
camelCase (messageId, conversationId). Without this annotation,
p.messageId was always undefined and every message was silently
dropped by the guard in applyRealtimeEvent.
* fix(chat): deduplicate realtime echo of own sent messages
Now that WebSocket events carry full message content (not just stubs),
applyRealtimeEvent appends a second copy when the echo of your own
sent message arrives — causing duplicate rendering.
Deduplicate by checking both messageId (post-ack) and clientNonce
(pre-ack race) before appending. Incoming agent/bot messages pass
through normally since they match neither.
* fix(chat): append thread reply messages to ThreadPanel message list
applyRealtimeEvent previously only incremented replyCount on the parent
message for thread replies (threadParentId set) but never appended the
reply itself to the messages array. The ThreadPanel has its own
useHistory instance with a separate query key keyed by threadParentId,
so it needs the actual reply message in its list to render it.
Now: validate + dedup first (shared for all message types), then:
- threadParentId set → bump parent.replyCount AND concat newMessage
- no threadParentId → just append (main chat)
* fix(chat): idempotent message append via merge-by-Map in applyRealtimeEvent
Root cause of thread panel duplicates: .concat(newMessage) always added
a copy even when a message with the same ID already existed (from
optimistic add, API ack, or history load).
Now uses Map-based merge (same pattern as mergeHistoryMessages):
- Build Map from existing messages keyed by ID
- Map.set(messageId, newMessage) overwrites if exists, inserts if new
- Sort by seq before returning
This makes append truly idempotent regardless of timing:
optimistic → WS echo → both have same ID after ack → one copy
history load → WS event → same messageId → one copy
Also adds client_nonce field to MessageCreatedPayload struct
(skip_serializing_if = None) for future echo dedup.
* docs: add field-level docs to MessageCreatedPayload
* fix: thread reply double-render and channel leak
Bug 1 (channel leak): applyRealtimeEvent appended thread-reply messages
into the main channel message list. Thread replies should only live
in ThreadPanel's own history cache. Fix: when threadParentId is set,
only bump replyCount on parent — don't inject the reply.
Bug 2 (thread double-render): WS event payload always sent client_nonce=null,
so dedup by nonce failed when WS arrived before HTTP response (race
condition). Fix: thread clientNonce through backend chain
(send_message_to_channel → create_message → to_event_payload) so
the WS event echoes it back for reliable dedup.
* refactor: split realtime event handling into normalize-route-upsert pipeline
Before: applyRealtimeEvent was a 40-line monolith mixing validation,
field mapping (WS→cache), routing (thread vs channel), and dedup/
merge logic in one switch statement. This caused Bug #1 (thread replies
leaking into channel list) and Bug #2 (double-render) because the
function had no context about WHO was calling it.
After: three focused functions with clear single responsibilities:
- normalizeEvent(event): validate + map WS payload → HistoryMessage | null
(ONE place for all field mapping, returns null for invalid/ignored)
- upsertMessage(list, msg): pure dedup-by-ID-or-nonce + Map merge + sort
(no routing, no field mapping, just set semantics)
- bumpReplyCount(list, parentId): increment replyCount on parent message
(thread-specific side-effect, explicitly called by caller)
Routing lives in useHistory.onFrame where isThreadView context is known:
if (msg.thread_parent_id && !isThreadView) → bumpReplyCount only
else → upsertMessage
Tests: 12 unit tests (normalize, upsert, bumpReplyCount), 7/7 QA pass,
45/45 Rust tests pass.
* fix(agents): group model dropdown by provider and cap height
Replace flat model list with provider-grouped sections using
SelectGroup/SelectLabel. Display only model name (not full
provider/model path) in items. Add max-h-[320px] scroll to prevent
unbounded dropdown growth.
* fix: suppress refetch when WS event nonce matches optimistic message
Root cause of channel message double-render: when you send a message,
the WS event arrives with clientNonce matching the optimistic entry.
upsertMessage deduped by nonce (correct) but returned unchanged
list, so messageCountChanged stayed false. Code fell through to
refetch() at line 157, which fetched the persisted message from server
and merged it via mergeHistoryMessages — adding a SECOND copy because
the optimistic entry still had its temp ID (ack hadn't run yet).
Fix: check nonce match BEFORE upsertMessage. If event's clientNonce
matches any existing cache entry, return current unchanged and skip
the entire fallback chain (incremental fetch + refetch). The HTTP
ack path handles tempId→realId promotion.
* feat: add suppressEvent flag to eliminate sender echo race condition
When a client sends a message with suppressEvent=true, the backend
skips broadcasting the message.created WebSocket event back to that
sender. The sender already has an optimistic copy and promotes it on
HTTP ack — no need for a WS echo that creates a dual-write race.
Backend:
- Add suppressEvent field to SendRequest + PublicConversationSendRequest
- Thread through send_message_to_channel -> create_message
- Conditionally skip stream_tx.send when suppress_event=true
Frontend:
- Add suppressEvent to SendMessageRequest type
- Always set suppressEvent=true in MessageInput, ThreadPanel (send+retry)
- Revert nonce early-return band-aid (no longer needed)
* feat(chat): extract MessageList base component with unread tracking
- Add MessageList reusable component (DM/Channel/Thread shared)
- Scroll container, message rendering, bottom-stick, initial scroll
- NewMessageDivider (inline line between read/unread)
- NewMessageBadge (fixed bottom-right, click-to-scroll)
- Global unread ID tracking in UIStore (per-conversation Set)
- addUnreadMessageId / markUnreadAsSeen / clearAllUnread actions
- Sender's own messages auto-marked as read
- Badge/divider clear on scroll-to-bottom or badge click
- Remove dead visibility-tracking hooks from ChatPanel/ThreadPanel
- Add UNR-001/UNR-002 e2e tests for badge clear on scroll
* feat: remove client-side optimistic updates and refactor unread tracking
refactor(chat): simplify message handling by removing clientNonce and optimistic updates
style: fix formatting in test files and realtime_tests.rs
test: add MessageList scroll position tests
docs: add design doc for unread tracking refactor
test: add e2e tests for unread tracking behavior
refactor(ui): streamline message list scrolling and unread tracking logic
* feat: refactor realtime transport and session management; remove legacy code and improve message handling
* feat: remove unused functions and optimize message handling in data layer
* feat: enhance chat components with conversationId for improved read tracking and refactor inbox state management
* feat: refactor inbox state management by removing unused read cursor handling and optimizing related components
* feat: rename scheduleCheck to scheduleReadCheck and optimize scroll behavior for message visibility
* feat: implement gap message fetching in useHistory for improved message continuity
* fix(ci): repair PR 32 build checks
* refactor(messages): remove client nonce from create_message API
* refactor(chat): reorganize MessageList sections for readability
Group refs by purpose (DOM, read-cursor, target-tracking), co-locate
buildVisibilityItems with scheduleReadCheck, move derived state before
effects, collect all scroll effects under one section header, move
handleScrollToBottom to a Handlers block before JSX.
Merged
7 tasks
Fullstop000
added a commit
that referenced
this pull request
Apr 26, 2026
…times (#106) * feat(drivers): restore standing system prompt builder Restores the standing system prompt deleted in 86299f9 (v2 RuntimeDriver refactor). Adds src/agent/drivers/prompt.rs exporting build_system_prompt(spec, Transport, &PromptOptions) -> String, lifted from @slock-ai/daemon@0.40.2 with three deliberate edits: brand Slock -> Chorus, threads removed (Chorus does not implement them), tool names rendered bare by default (Claude binds with mcp__chat__ prefix and overrides at the call site). Per-driver wiring lands in subsequent tasks; this commit only adds the builder. 8 tests cover the canonical text + variant deltas (CLI vs MCP, tool_prefix, persona override, extra_critical_rules). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(drivers/prompt): drop dead Transport::Cli variant No driver in this plan uses CLI transport, and Chorus does not ship a user-facing CLI for messaging. Removing the variant cuts ~80 LOC of unused branches plus one test. If we ever ship a CLI, add the variant then. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(drivers/claude): inject standing prompt via --append-system-prompt Replaces the direct spec.system_prompt forwarding with a build_system_prompt call. tool_prefix='mcp__chat__' because Claude binds the chat MCP server as 'chat' and surfaces tools as mcp__chat__send_message etc. (see --allowedTools mcp__chat__* on line 534). Stdin notification section enabled with Poll style (Claude exits between turns; messages arrive as system notifications on the next spawn). The user's per-agent persona override (spec.system_prompt) is now consumed inside the builder as the trailing '## Initial role' section instead of replacing the standing prompt entirely. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(drivers/codex): inject standing prompt via developerInstructions Three bug fixes in one commit (all on the codex JSON-RPC schema, all surfaced during research for the standing-prompt restoration): 1. thread/start: replace personality with developerInstructions. personality is a 3-value enum (none|friendly|pragmatic) per the codex `app-server generate-json-schema --experimental` output (verified codex 0.125). Pushing a multi-thousand-character string at it returned -32600 "unknown variant", which means EVERY Chorus codex agent with a configured system prompt was failing thread/start outright. developerInstructions is the documented free-form slot. 2. thread/resume: take developer_instructions and forward it. Without this the standing prompt was lost on resume. 3. initialize: use `capabilities` (schema-correct), not `clientCapabilities`, and drop `protocolVersion` (not in the schema). Both were silently discarded by codex; capability negotiation was a no-op. Wires the codex driver to call build_system_prompt with tool_prefix="" (ACP-style bare names), Direct notification style (codex stays alive across turns and pushes new messages directly into the live session), and the "process stays alive" post-startup note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(drivers/kimi): prepend standing prompt to first session/prompt turn Two native injection paths exist but neither works for Chorus: - kimi acp + --agent-file: silently ignored. The acp subcommand at kimi_cli/cli/__init__.py:1003 constructs ACPServer() with no agent_file argument. Verified empirically. - kimi --wire + --agent-file: works, but wire is single-session-per-process (wire/jsonrpc.py:218 exposes no session/new). Adopting wire would break Chorus's multi-session multiplexing for Kimi only. Solution: keep `kimi acp` for multi-session, ride the standing prompt as leading user-role text on the bootstrap turn. Subsequent turns (prompt()) unchanged. Cost is ~6KB tokens once per session and slightly weaker compliance than a real system prompt; benefit is the multi-session architectural invariant stays uniform across drivers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(drivers/gemini): inject standing prompt via GEMINI_SYSTEM_MD env var Captures Gemini's built-in baseline lazily on first agent spawn via `GEMINI_WRITE_SYSTEM_MD=<path> gemini -p ping` (cached at `<wd>/.chorus/gemini-baseline.md` for subsequent spawns), prepends it to Chorus's standing prompt, writes the combined file to `<wd>/.chorus/gemini-system.md`, and points GEMINI_SYSTEM_MD at the absolute path on the spawn env. GEMINI_SYSTEM_MD is loaded by config.initialize() which the ACP initialize handler calls (verified via upstream packages/cli/src/acp/acpClient.ts:176), so it applies to every session/new for the process lifetime. The env var is a *full replacement* for Gemini's built-in prompt per the docs, so the baseline must be included or Gemini loses its safety/approval/tool-use rules. Also sets GEMINI_CLI_TRUST_WORKSPACE=true so Gemini doesn't refuse to run in untrusted workspaces. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(drivers/opencode): inject standing prompt via opencode.json instructions Writes the standing prompt to <wd>/.chorus/opencode-system.md and adds `"instructions": [".chorus/opencode-system.md"]` to the opencode.json Chorus already writes. opencode merges every file in `instructions` into the model context at session start (verified per opencode.ai/config.json schema). The relative path resolves against the spawned process cwd, which matches Chorus's --cwd <wd> pattern. Confirmed empirically: works for every session/new on the same opencode acp process. The marker test returned the injected codename in both session #1 and session #2 of the same process, so this single instructions write covers all of Chorus's multi-session multiplexing for OpenCode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(drivers): document standing system prompt injection Adds a 'Standing System Prompt' section to DRIVER_GUIDE.md showing how to call build_system_prompt and listing each shipping driver's injection channel. Tightens the existing 'Tool Naming Rule' to mention tool_prefix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: log standing-prompt restoration in TODOS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(drivers): atomic-rename file writes for concurrent-spawn safety Gemini and OpenCode wrote system-prompt and config files via tokio::fs::write, which truncates first. Two concurrent spawns of the same agent (sharing a working directory) could observe a truncated file mid-write and feed an empty system prompt to the LLM. Same TOCTOU on the gemini baseline capture: the existence check could race the subprocess invocation. Switch every shared-path write to write-tmp + rename. POSIX rename is atomic when both paths are on the same filesystem (always true here — same .chorus dir). Concurrent writers race the rename; whichever wins leaves intact content (both writers built from the same spec, so contents are identical). Surfaced by Gemini's review of the branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: bump version and changelog (v0.0.3.0) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(drivers): address Copilot review comments + cargo fmt Review feedback on PR #106: - prompt.rs: clarify upload_file/view_file are image-only (JPEG/PNG/GIF/WebP, 5MB max) to match what ChatBridge actually accepts. The earlier "any file" wording would have agents attempting unsupported uploads. - prompt.rs: align the Messaging header docs with what the bridge actually emits. The bridge formatter (src/bridge/backend.rs:276) only stamps `type=agent` on agent senders and omits the field for humans. The earlier prompt claimed `type=` was always present with values `human|agent|system`. Fixed by saying type= is optional and present only for agent senders. - prompt.rs: stop teaching the agent that its display_name is its @mention handle. Display names are non-canonical (user-editable, not unique). Reworded to point at the `@<sender_name>` shown on the agent's own messages in read_history, which IS the canonical handle. Threading the canonical name through AgentSpec would have been a bigger change for marginal benefit. - gemini.rs + opencode.rs: tmp filenames used only `std::process::id()` as disambiguator, so two concurrent same-process spawns of the same agent could collide and clobber each other mid-rename, defeating the atomic-publish goal of the previous race-fix commit. Add a UUID v4 suffix per call. - cargo fmt fixes for the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(changelog): trim v0.0.3.0 entry to user-facing two-liner Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fullstop000
added a commit
that referenced
this pull request
Apr 27, 2026
… pointer (#117) Address self-review code smells flagged on PR #117: #1 — Three `*_for_test` shims leaked module internals just to bridge the sibling-module boundary between `acp_native::tests` and the files under test. Replaced with two visibility tightenings and one test relocation: - `AcpNativeHandle::alloc_id` is now `pub(super)`. Deleted `alloc_id_for_test`. Tests call `alloc_id()` directly. - `reader::handle_response` is now `pub(super)`. Deleted `handle_response_for_test`. Tests call `handle_response(...)` directly. - The three `close()` multi-session tests moved into `handle.rs::tests` as an inline `#[cfg(test)] mod tests` block. Inside the same module they construct `AcpNativeHandle` with private field access — no `set_session_for_test` setter shim required. Deleted that shim too. To support tests in multiple files, factored shared fixtures (`TEST_CFG`, `TEST_REGISTRY`, `test_spec`, `make_core`, `open_test_session`, `fresh_shared`) into a new `acp_native/test_fixtures.rs` gated on `#[cfg(test)]`. Both `acp_native::tests` and `acp_native::handle::tests` import from it. #2 — `InitPromptStrategy::Deferred` was annotated `#[allow(dead_code)]` "for future runtimes." YAGNI. Deleted the variant. The enum stays as a single-variant enum (rather than collapsing to "always immediate" behavior) so a future driver that genuinely needs to defer can extend without a wire-shape breaking change. Doc-comment on the enum explains why. #4 — `AcpDriverConfig::registry` was `fn() -> &'static AgentRegistry<...>` wrapping a function-local static. Hoisted each driver's static to module level (`KIMI_REGISTRY`, `GEMINI_REGISTRY`, `OPENCODE_REGISTRY`, `TEST_REGISTRY`) and changed the field type to `&'static AgentRegistry<AcpNativeCore>`. Removed the `(cfg.registry)()` call indirection at every use site. `AgentRegistry::new` is `const fn` so this just works. Verified: cargo fmt --check (clean), cargo test --lib (324 passed), cargo clippy --lib --tests -- -D warnings (clean). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fullstop000
added a commit
that referenced
this pull request
Apr 27, 2026
…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
added a commit
that referenced
this pull request
Apr 27, 2026
…encode (#111) (#117) * refactor(drivers): extract shared acp_native base from gemini/kimi/opencode (#111) Three ACP-native drivers (gemini, kimi, opencode) each carried ~1500-2700 lines of structurally near-identical code: reader loop, response routing, session lifecycle, cancel/close, ensure_started semantics, EOF drain, permission auto-approval. Bug fixes had to be applied in three places and behaviors drifted apart at edges. Move all of it into `src/agent/drivers/acp_native/`: - `mod.rs` — `AcpDriverConfig` (struct of fn pointers + bools + enums) + `InitPromptStrategy` + shared `open_session` helper. - `state.rs` — `SharedReaderState`, `PendingRequest`, `SessionState`. - `core.rs` — `AcpNativeCore`, `ensure_started` (race-safe lazy spawn, non-sticky failure), `spawn_and_initialize`, `is_stale`, `Drop`. - `handle.rs` — `AcpNativeHandle` + full `Session` impl: run, prompt, cancel, close (with `closed_emitted` race guard). - `reader.rs` — `reader_loop`, `handle_response`, `handle_session_update`, `pick_session*`. Routes responses by JSON-RPC id through `pending_requests` map (avoids `acp_protocol::parse_line`'s id-bucketing that misclassifies `session/new` at id≥3 as PromptResponse). - `tests.rs` — 19 generic tests with a `TestConfig`. Audit table at top maps each pre-migration per-driver test to its shared equivalent. Per-runtime variation lives entirely in the static `&'static AcpDriverConfig` each driver owns. No trait, no generics — three runtimes, single instantiation, function-pointer dispatch. Behavior preserved bit-for-bit: - Cancel stays local-only. - `stopReason` continues to be ignored; all completions emit Natural. - `session/close` remains local-only (no RPC). - No capability checking before `session/load`; no `session/resume`. - HTTP MCP transport stays as-is. The 8 ACP spec gaps catalogued in the plan are tracked as follow-up issues — each becomes a 1-place fix now that the base is shared. New shared test `ensure_started_concurrent` closes a coverage gap: drives two concurrent `ensure_started` calls and asserts the slow path runs exactly twice (each caller retries after its predecessor fails, proving serialization + non-stickiness without needing a real runtime binary). Opencode shape conversion: collapse the `FactoryPath::Bootstrap | Secondary` split into the unified handle. The race the bootstrap protected against (deferred prompt id colliding with a racing secondary `new_session`) cannot occur in the unified model — `ensure_started` serializes through `start_in_progress`, and `alloc_id` runs only after that mutex is released. Deletes `OpencodeAgentProcess`, `FactoryPath`, `run_bootstrap*`, `send_deferred_bootstrap_prompt`, the local `classify_line`/`dispatch_line`, and the bootstrap-only state fields (`bootstrap_pending_prompt`, `bootstrap_session_id`, `bootstrap_requested_session_id`). Diff: - gemini.rs 1718 → 423 (−75%) - kimi.rs 2737 → 328 (−88%) - opencode.rs 2834 → 339 (−88%) - net: 7289 → 3650 lines (−50% across drivers + new shared module incl. tests) Verified: cargo test (527 passed), cargo test --test e2e_tests (10 passed), cargo clippy --lib --tests -- -D warnings (clean). Plan: docs/plans/2026-04-27-acp-native-driver-unification-plan.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(acp_native): rustfmt + address Copilot PR review CI fixes: - cargo fmt across acp_native module + per-driver wrappers (rustfmt rules prefer multi-line struct/match args). PR review (Copilot): - mod.rs: scrap reference to gitignored docs/plans/* file; point readers at issue #111 + the PR description for spec gap context. - mod.rs: rewrite InitPromptStrategy::Deferred docstring — opencode no longer needs it. Kept as a config knob for future runtimes that genuinely defer the first prompt. - tests.rs: ensure_started_concurrent now expects each tokio JoinHandle so a panic in either task fails the test instead of getting masked. - tests.rs + handle.rs: add the missing `alloc_id_starts_at_3_after_spawn_and_initialize` shared test the audit table claimed existed. Tests that the first allocated id after ensure_started seeds next_request_id=3 is exactly 3, and that no id-3 placeholder is pre-registered. Exposed via a #[cfg(test)] alloc_id_for_test shim on AcpNativeHandle. Verified locally: cargo fmt --check (clean), cargo test --lib (324 passed, +1 vs prior count for the new alloc_id test), cargo clippy --lib --tests -- -D warnings (clean). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(acp_native): tighten API — drop test shims, dead variant, fn pointer (#117) Address self-review code smells flagged on PR #117: #1 — Three `*_for_test` shims leaked module internals just to bridge the sibling-module boundary between `acp_native::tests` and the files under test. Replaced with two visibility tightenings and one test relocation: - `AcpNativeHandle::alloc_id` is now `pub(super)`. Deleted `alloc_id_for_test`. Tests call `alloc_id()` directly. - `reader::handle_response` is now `pub(super)`. Deleted `handle_response_for_test`. Tests call `handle_response(...)` directly. - The three `close()` multi-session tests moved into `handle.rs::tests` as an inline `#[cfg(test)] mod tests` block. Inside the same module they construct `AcpNativeHandle` with private field access — no `set_session_for_test` setter shim required. Deleted that shim too. To support tests in multiple files, factored shared fixtures (`TEST_CFG`, `TEST_REGISTRY`, `test_spec`, `make_core`, `open_test_session`, `fresh_shared`) into a new `acp_native/test_fixtures.rs` gated on `#[cfg(test)]`. Both `acp_native::tests` and `acp_native::handle::tests` import from it. #2 — `InitPromptStrategy::Deferred` was annotated `#[allow(dead_code)]` "for future runtimes." YAGNI. Deleted the variant. The enum stays as a single-variant enum (rather than collapsing to "always immediate" behavior) so a future driver that genuinely needs to defer can extend without a wire-shape breaking change. Doc-comment on the enum explains why. #4 — `AcpDriverConfig::registry` was `fn() -> &'static AgentRegistry<...>` wrapping a function-local static. Hoisted each driver's static to module level (`KIMI_REGISTRY`, `GEMINI_REGISTRY`, `OPENCODE_REGISTRY`, `TEST_REGISTRY`) and changed the field type to `&'static AgentRegistry<AcpNativeCore>`. Removed the `(cfg.registry)()` call indirection at every use site. `AgentRegistry::new` is `const fn` so this just works. Verified: cargo fmt --check (clean), cargo test --lib (324 passed), cargo clippy --lib --tests -- -D warnings (clean). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * refactor(acp_native): unify pick_session into pick_session_and_run (#117) Self-review followup: the two pick_session* helpers had identical hint resolution logic (lock, single-session fallback, warnings) and differed only in whether the return value bundled the session's run_id. Six call sites were split arbitrarily — Thinking/Text used pick_session_and_run, ToolCall/ToolCallUpdate/ToolResult/TurnEnd used pick_session. ~30 lines of duplicated logic for a single Option<Uuid> field lookup. Folded into one function: `pick_session_and_run` returning `(Option<String>, Option<RunId>)`. Callers that don't want run_id destructure with `_`. Behavior preserved bit-for-bit — same lock policy, same warn messages (renamed to driver-agnostic "session-update" since they no longer name a specific helper). Verified: cargo fmt --check (clean), cargo test --lib (324 passed), cargo clippy --lib --tests -- -D warnings (clean). Re acp_protocol.rs location: keep at drivers::acp_protocol. It's the ACP wire-format layer (JSON-RPC parsing + frame builders), used by acp_native AND by event_forwarder::strip_mcp_prefix outside acp_native. Moving into acp_native would imply ownership it doesn't have. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fullstop000
added a commit
that referenced
this pull request
Apr 27, 2026
…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
added a commit
that referenced
this pull request
Apr 28, 2026
…ng (#116) * feat: system message when member joins a channel (#114) 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 * fix: ensure system message on agent creation by moving auto-join out 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. * refactor: eliminate join_channel duplication, promote system-message 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 * fix(copilot-review): use stable IDs in template handler, add UUID human 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) * style: cargo fmt * refactor: unify system-message structured payloads 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> * chore: bump version and changelog (v0.0.4.0) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: simplify v0.0.4.0 changelog entry Drop implementation detail in favor of two user-facing bullets. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced Apr 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
~/.chorus/chorus.db); message delivery via tokio broadcast channels_old/for referenceArchitecture
Single binary with two modes:
chorus serve— starts local HTTP API server (axum) + agent process managerchorus bridge --agent-id <id>— MCP stdio server spawned by agent processes, proxies 11 chat tools to local HTTP APIWhat's included
src/models.rssrc/store.rssrc/server.rssrc/drivers/src/agent_manager.rssrc/bridge.rssrc/main.rsserve,bridge,send,history,status,channel,agentTest plan
cargo build --releaseproduces 9.9MB binary