From afc1efdf11b82db08ee3b846f8d30868f88c2da8 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Mon, 1 Jun 2026 17:38:50 +0800 Subject: [PATCH 1/6] Docs: Add participant architecture --- docs/participant-architecture.md | 835 ++++++++++++++++++++++++++++ docs/participant-architecture.zh.md | 646 +++++++++++++++++++++ 2 files changed, 1481 insertions(+) create mode 100644 docs/participant-architecture.md create mode 100644 docs/participant-architecture.zh.md diff --git a/docs/participant-architecture.md b/docs/participant-architecture.md new file mode 100644 index 00000000..6792ae10 --- /dev/null +++ b/docs/participant-architecture.md @@ -0,0 +1,835 @@ +# Participant Identity Architecture and API Plan + +## Quick Review Points + +- The core change is to separate `Participant`, `Agent`, `ChannelUser`, and the + product-facing word `Bot`: participants are the collaboration identities used + by rooms, messages, members, and mentions; agents are runtime execution + entities; channel users are channel-internal identity/profile records; bot is + no longer a backend API or storage model. +- When the UI creates an Agent that should appear in the built-in CSGClaw IM, it + should call `POST /api/v1/channels/csgclaw/participants` with `type=agent` and + `agent_binding.mode=create`. The server provisions the Agent, ChannelUser, and + Participant in one operation. +- Cross-channel reuse no longer depends on equal IDs. One Agent can have many + participants, such as `csgclaw:u-qa -> agent:u-qa` and + `feishu:u-test -> agent:u-qa`. +- Mentions belong to the participant identity layer. Feishu human mentions + resolve through `channel_user_ref` plus `channel_app_ref` / + `channel_user_kind`; humans do not need their own bot app/config. +- A notification is a `type=notification` participant representing a webhook, + system event, or pull relay source. It does not bind to an Agent or expose an + LLM bridge by default. +- New message APIs use `mentions` / `mention_ids` arrays; room membership moves + from `user_ids` to `participant_ids` or structured `ParticipantRef` values. +- Deleting a participant deletes only the channel identity binding by default, + not the underlying Agent. Agent cleanup requires explicit semantics such as + `delete_agent=if_unreferenced`. +- This is a coordinated breaking API change across frontend, backend, runtime + bridge, and embedded templates: old bot routes and public `/users` routes do + not keep compatibility aliases. +- Matrix alignment is limited to identity, membership, message, mention, and + thread shapes touched by this work. It does not implement a full Matrix + homeserver or Client-Server API. + +## Background + +CSGClaw currently uses `bot`, `agent`, and channel `user` concepts in ways that +work for the first local multi-agent workflow, but do not scale cleanly to +multi-channel and human-in-the-loop collaboration. + +The immediate problem is mention identity. Agents need to mention real people in +the built-in CSGClaw IM, Feishu, and future IM integrations. The current bot +model assumes that message senders and mentions are bot-like identities. In +Feishu, that means a mention is resolved through configured bot app identities, +so an agent cannot cleanly mention a real human open_id. + +There is also a naming and ownership problem. The UI exposes many workflows as +agent management, while parts of the client call channel bot APIs. At the same +time, the underlying runtime agent is already reusable across channels. For +example, a Feishu bot can be modeled as a Feishu channel identity plus an +underlying agent that was originally created from the CSGClaw channel. Today +that reuse depends heavily on matching IDs such as `u-manager`. If the CSGClaw +agent is `u-qa` and the Feishu-facing bot is `u-test`, the relationship cannot +be represented cleanly. + +The target design separates these concerns: + +- `Participant` is the collaboration identity used by rooms, messages, members, + and mentions. +- `Agent` is the runtime execution entity that owns model, profile, lifecycle, + logs, and sandbox state. +- `Bot` is removed from the API and storage model. It may remain only as UI copy + where the product intentionally says "bot" to a user. + +## Goals + +- Agents can mention real people in CSGClaw IM, Feishu, and future IMs. +- Rooms and messages can include humans, agent-backed participants, and + notification participants with one unified participant reference. +- One underlying agent can be reused by many channel participants without + relying on equal IDs. +- `GET /api/v1/agents` can show all runtime agents and the participants that + expose them in any supported IM, including Feishu. +- The UI can support both creating a new agent-backed participant and adding an + existing agent to another channel without forcing users to understand the + internal model first. +- The frontend, backend, runtime bridge, and embedded templates are updated + together. Existing bot routes are removed rather than kept as old aliases. +- The new shape stays close to Matrix concepts where this work already touches + rooms, members, messages, mentions, and threads. + +## Non-Goals + +- Do not merge the same real person across multiple channels in the first + implementation. A later `Identity` layer can group channel-specific human + participants if product requirements need cross-channel audit or permissions. +- Do not require every participant to have an agent. Humans and notification + participants do not need runtime execution. +- Do not implement a full Matrix homeserver or complete Client-Server API in + this change. Only the identity, membership, message, mention, and thread + shapes touched by this work should be Matrix-friendly. + +## Target Model + +### Agent + +An agent is global to CSGClaw and independent of any channel. + +```text +Agent + id + name + role + runtime_id + runtime_kind + image + runtime_options + status + agent_profile + created_at +``` + +Rules: + +- Agent IDs are global. +- Agent lifecycle operations remain under `/api/v1/agents`. +- Agent profile, model, runtime, logs, start, stop, restart, and recreation stay + owned by the agent service. + +### Participant + +A participant is the identity visible inside one channel. + +```text +Participant + id # stable within its channel + channel # csgclaw | feishu | matrix | ... + type # human | agent | notification + name + avatar + channel_user_ref # csgclaw user id, feishu open_id, matrix user_id, ... + channel_user_kind # local_user_id | open_id | matrix_user_id | ... + channel_app_ref # optional, for bot app/config identity such as Feishu app_id + agent_id # optional FK -> Agent.id, only meaningful for type=agent + lifecycle_status # provisioning | active | disabled | failed + presence # optional channel presence or room member view + mentionable + metadata + created_at + updated_at +``` + +Rules: + +- The canonical participant key is `(channel, id)`. +- `channel_user_ref` is required for participants that can send, receive, or be + mentioned in the channel. +- Active participants should be unique by that channel's channel-user identity. + For simple channels this is `(channel, channel_user_ref)`. For app-scoped + identities such as Feishu, `channel_app_ref` and `channel_user_kind` must also + be part of the unique key. +- `lifecycle_status` describes whether the participant record itself is usable; + `presence` describes the channel or room online/offline view; runtime running + state comes from the bound Agent and should not be persisted on the participant. +- `mentionable=false` means the participant may remain visible in lists or + history, but should not be available as a new mention target. +- `type=human` must not require `agent_id`. +- `type=notification` must not require `agent_id`. +- `type=agent` may bind an existing agent, create a new agent, or be registered + without a runtime binding and bound later. +- A single agent can have many participants across channels: + +```text +csgclaw:u-qa -> agent:u-qa +feishu:u-test -> agent:u-qa +matrix:qa-bot -> agent:u-qa +``` + +### Channel User / Channel Identity + +The current public `User` model should not remain as a top-level product API +after participants are introduced. It should be replaced by participant APIs. + +A user-like record is still needed internally, but its meaning changes: it is a +channel-scoped identity/profile record owned by the channel adapter, not the +primary collaboration identity. + +```text +ChannelUser + channel # csgclaw | feishu | matrix | ... + ref # csgclaw local user id, Feishu open_id/union_id, Matrix user_id + kind # local_user_id | open_id | matrix_user_id | ... + app_ref # optional, used for Feishu app_id/tenant-scoped identity + display_name + handle + avatar_url + presence + raw_profile + updated_at +``` + +Rules: + +- `Participant.channel_user_ref` points to a channel user or equivalent + channel-native identity. +- For the built-in CSGClaw channel, this internal record replaces the current + `User` storage shape for profile, avatar, handle, and presence. +- For Feishu, this is an adapter-owned record. With `kind=open_id`, `ref` is an + app-scoped open_id and must be interpreted together with `app_ref`. If later + implementations use `union_id` or `user_id`, that choice must be explicit in + `kind`. +- For Matrix, this maps naturally to Matrix user IDs and member profile fields + such as `displayname`, `avatar_url`, and membership state. +- Public clients should create, list, update, and delete people through + participants. They should not call a separate `/users` API. + +### Feishu Identity Scope + +Feishu user identity cannot be modeled as a bare `open_id` string. The same real +person can have different IDs across apps, tenants, or identity types, and the +same string must not be reused across scopes implicitly. + +Rules: + +- `channel_app_ref` identifies the Feishu app/config for this participant, for + example `cli_xxx`. +- With `channel_user_kind=open_id`, `channel_user_ref` is the open_id in that app + scope. The unique key should include at least + `(channel, channel_app_ref, channel_user_kind, channel_user_ref)`. +- `type=agent` participants need `channel_app_ref` so the sender credentials are + unambiguous. +- `type=human` participants use `channel_user_ref` as the mention target and do + not require that human to have a bot app/config. +- If an adapter receives only `user_id`, `union_id`, or another identity from a + Feishu event, it should store that raw identity with an explicit + `channel_user_kind` first, then resolve it explicitly when sending or + mentioning. It must not treat the value as a bot ID implicitly. + +### Notification Participant + +A notification is also a channel participant, but by default it is not a runtime +agent. + +```text +Participant(type=notification) + channel_user_ref # notification sender identity or local webhook identity + channel_app_ref # optional, app/config needed by an external channel + metadata.notification # webhook, remote_pull, subscription, delivery config +``` + +Rules: + +- Notification participants can send room messages so system notifications, + third-party webhooks, or pull relays have a visible source. +- Notification participants do not have `agent_id` by default and do not expose an + LLM bridge. +- Notification webhook/pull config belongs on participant metadata or a dedicated + notification profile, not on a bot-shaped API. +- The old `POST /api/v1/channels/csgclaw/bots/{id}/notifications` should become a + participant-scoped notification endpoint: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +### Room, Message, and Mention + +Rooms and messages should reference participants, not agents. + +```text +Room + channel + members: ParticipantRef[] + +Message + event_id + room_id + sender: ParticipantRef + mentions: ParticipantRef[] + content + relates_to + +ParticipantRef + channel + id +``` + +New APIs should avoid bot-shaped names. Fields such as `sender_id`, +`member_ids`, and `user_ids` are acceptable only when their meaning is explicitly +participant or Matrix user identity, not legacy bot identity. New message APIs +should prefer `mentions: ParticipantRef[]` or `mention_ids: []`, not a single +`mention_id`. Where ambiguity exists, prefer `participant_id`, +`participant_ids`, or a structured `ParticipantRef`. + +### Future Chat History Sync Compatibility + +Chat history sync is not part of this implementation plan. It is a compatibility +constraint for the participant model. + +If a user chats in Feishu and CSGClaw later syncs the room, the imported message +must be able to pass through the same participant resolution path as locally +created messages. The participant model therefore must not assume that every +message was authored locally or that every sender is an agent-backed +participant. + +Future sync work should be able to add a `MessageEvent` or adjacent sync record +with fields such as: + +- `external_room_ref`, such as Feishu `chat_id` or Matrix room ID; +- `external_event_id`, such as Feishu `message_id` or Matrix event ID; +- `origin_server_ts` from the source IM; +- `received_at` as the local ingestion time; +- `sync_batch` or cursor metadata for resumable sync; +- `raw_event` as a redacted or bounded source payload for debugging. + +This plan should not implement sync storage, sync APIs, or backfill jobs now. +It only keeps sender, mention, room, and event identity compatible with that +future work. + +### Optional Future Identity Layer + +If CSGClaw later needs to know that the same person appears as a CSGClaw local +user, Feishu open_id, and Matrix user_id, add an identity grouping layer: + +```text +Identity 1 -> N Participant(type=human) +``` + +This is not required to solve agent-to-human mentions or cross-channel agent +reuse. + +## Matrix Alignment + +The planned Matrix direction should influence the new participant model where it +overlaps with this work. The goal is not to implement the full Matrix +Client-Server API now, but to avoid creating a second incompatible IM model. + +Use the following alignment points: + +- Matrix user IDs map to `Participant.channel_user_ref` with + `channel_user_kind=matrix_user_id`, for example `@qa:example.org`. +- Matrix room IDs map to channel room references, for example + `!roomid:example.org`; room aliases can be stored as channel metadata. +- Room membership should be representable as `m.room.member` state: + `membership`, `displayname`, and `avatar_url` belong on the room membership or + participant view, not on the runtime agent. +- Text messages should be representable as `m.room.message` events with + `msgtype=m.text`, `body`, and optional `format` / `formatted_body`. +- Mentions should be representable as Matrix `m.mentions`, especially + `m.mentions.user_ids` for user mentions and `m.mentions.room` for room + mentions. +- Existing thread metadata should continue to use Matrix-shaped + `m.relates_to.rel_type=m.thread` and root event IDs. +- Future CSGClaw sync APIs should follow the same high-level shape as Matrix + `/sync`: clients receive joined room timelines and a resumable batch token + similar to `next_batch` / `since`. + +This makes CSGClaw's own IM easier to move toward Matrix later while still +letting Feishu and other adapters keep their native transport details. + +## API Plan + +### Participant APIs + +Introduce participant APIs under the channel namespace. These replace the old +channel bot CRUD routes. + +```text +GET /api/v1/channels/{channel}/participants +POST /api/v1/channels/{channel}/participants +GET /api/v1/channels/{channel}/participants/{id} +PATCH /api/v1/channels/{channel}/participants/{id} +DELETE /api/v1/channels/{channel}/participants/{id} +``` + +The following routes should be deleted: + +```text +GET /api/v1/channels/{channel}/bots +POST /api/v1/channels/{channel}/bots +GET /api/v1/channels/{channel}/bots/{id} +PATCH /api/v1/channels/{channel}/bots/{id} +DELETE /api/v1/channels/{channel}/bots/{id} +GET /api/v1/channels/feishu/bots/{id}/events +POST /api/v1/channels/csgclaw/bots/{id}/notifications +GET /api/bots/{id}/events +POST /api/bots/{id}/messages/send +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +The current public user routes should also be removed from the product API +surface: + +```text +GET /api/v1/users +POST /api/v1/users +DELETE /api/v1/users/{id} +GET /api/v1/channels/csgclaw/users +POST /api/v1/channels/csgclaw/users +DELETE /api/v1/channels/csgclaw/users/{id} +``` + +Use participants instead: + +```text +GET /api/v1/channels/{channel}/participants?type=human +POST /api/v1/channels/{channel}/participants +``` + +List query parameters: + +- `type=human|agent|notification` +- `agent_id=` +- `include_agent=true` +- `include_channel_user=true` + +Create an agent-backed participant by creating a new agent: + +```json +{ + "id": "u-qa", + "type": "agent", + "name": "qa", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "agent_profile": { + "provider": "api", + "model_id": "gpt-5.4" + } + } + } +} +``` + +This endpoint is the replacement for the old UI "create bot" behavior that +created both an agent and a channel user. It must be implemented as one +provisioning operation owned by the server, not as UI-side chaining of +`POST /api/v1/agents` followed by participant creation. + +For the built-in CSGClaw channel, `agent_binding.mode=create` must create: + +```text +1. Agent +2. Channel user / Matrix-shaped member identity +3. Participant(type=agent, agent_id=) +``` + +The operation should commit only after all three resources are valid. If user or +participant creation fails after the agent is created, the server must either +roll back the created agent or mark the operation as failed and retry-safe with +an idempotency key. + +For external channels, true distributed transactions are not available. The API +still stays single-call from the UI, but the server must use idempotency and +compensation: + +- accept an optional `request_id` or `client_transaction_id`; +- make repeated requests with the same key return the same final resource; +- record partially completed steps; +- retry or compensate failed channel-user provisioning before exposing the + participant as active. + +Create a channel participant that reuses an existing agent: + +```json +{ + "id": "u-test", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "channel_app_ref": "cli_xxx", + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } +} +``` + +Create a human participant: + +```json +{ + "id": "human-alice", + "type": "human", + "name": "Alice", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } +} +``` + +Supported `agent_binding.mode` values: + +- `create`: create a new agent and bind it to this participant. +- `reuse`: bind this participant to an existing agent. +- `none`: create the participant without runtime binding. This is valid for + humans, notifications, and draft agent participants. + +Validation: + +- `type=human` rejects `agent_binding.mode=create`. +- `type=notification` rejects `agent_binding.mode=create` unless a future + notification runtime explicitly needs it. +- `type=agent` with `mode=reuse` requires `agent_id`. +- `type=agent` with `mode=create` requires enough agent fields to create a + valid worker or manager. +- Participant ID and agent ID are not required to match. + +Deletion rules: + +- `DELETE /api/v1/channels/{channel}/participants/{id}` deletes the participant + and its channel binding by default. It does not delete the underlying Agent. +- If a `type=agent` participant is bound to an Agent, deleting the participant + only removes that channel identity from the Agent. Other participants for the + same Agent are unaffected. +- If the caller wants to clean up an Agent that is no longer referenced, it must + use an explicit parameter such as `delete_agent=if_unreferenced`. If any other + participant still references that Agent, the server must reject Agent deletion. +- Channel users / channel identities may be cleaned up only when CSGClaw owns + them and no other active participant references them. External channels should + usually delete only the local mapping or mark it inactive, not delete the real + remote user. +- Deleting a notification participant should also remove local notification + profile, webhook token, and remote_pull subscription metadata. + +### Agent APIs + +Keep `/api/v1/agents` as the runtime-agent API for lower-level runtime +management: edit model/profile, start, stop, restart, recreate, delete, view +logs, and support internal provisioning flows. + +CSGClaw's own product UI should not use `POST /api/v1/agents` as the primary +"Create Agent" action when the result is expected to appear in the CSGClaw IM. +It should use the same participant provisioning API as third-party channels, +with `channel=csgclaw`. + +Extend list and detail responses with participant bindings. + +```text +GET /api/v1/agents?include_participants=true +GET /api/v1/agents/{id}?include_participants=true +``` + +Example response excerpt: + +```json +{ + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "status": "running", + "participants": [ + { + "id": "u-qa", + "channel": "csgclaw", + "type": "agent", + "channel_user_ref": "u-qa" + }, + { + "id": "u-test", + "channel": "feishu", + "type": "agent", + "channel_user_ref": "ou_xxx" + } + ] +} +``` + +This satisfies the requirement that the agent list includes agents represented +in any supported IM, including Feishu. + +### Runtime Bridge API Replacement + +Deleting `/api/bots/*` requires replacing each old bridge surface with an +explicit participant or agent scoped route. + +Participant event streams are channel identity concerns: + +```text +GET /api/v1/channels/{channel}/participants/{id}/events +``` + +This replaces both: + +```text +GET /api/v1/channels/feishu/bots/{id}/events +GET /api/bots/{id}/events +``` + +Participant-authored message sending is also a channel identity concern: + +```text +POST /api/v1/channels/{channel}/participants/{id}/messages +``` + +The sender comes from the path participant. The body contains `room_id`, +`content`, optional `mentions`, and optional thread relation fields. This +replaces: + +```text +POST /api/bots/{id}/messages/send +``` + +Notification delivery is also a channel identity concern: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +This replaces: + +```text +POST /api/v1/channels/csgclaw/bots/{id}/notifications +``` + +LLM bridge calls are runtime agent concerns, not channel identity concerns: + +```text +GET /api/v1/agents/{agent_id}/llm/models +POST /api/v1/agents/{agent_id}/llm/chat/completions +POST /api/v1/agents/{agent_id}/llm/responses +``` + +These replace: + +```text +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +If a runtime only knows its channel participant ID, it must resolve the +participant first and use `agent_id` for LLM calls. This keeps message identity +and model/runtime identity separate. + +### UI API Replacement + +The current UI issue is that an "Agent" workflow calls the deleted channel bot +API. The replacement is not UI-side chaining of multiple APIs. CSGClaw's own UI +must use the same model as Feishu and future third-party IMs: create a +participant in the target channel and bind or create an underlying agent. + +When CSGClaw's UI action means "create an Agent that can chat in CSGClaw IM", +call: + +```text +POST /api/v1/channels/csgclaw/participants +``` + +with `type=agent` and `agent_binding.mode=create` to create both the runtime +agent and the CSGClaw participant in one operation. This is the direct +replacement for the previous bot API that created both agent and user. + +When the UI adds an existing agent to another channel, use the same endpoint for +that channel with `agent_binding.mode=reuse`. + +The UI must not call `POST /api/v1/agents` and then separately create a user or +participant for this flow. That split can leave a created agent without a +channel identity, or a channel identity without a valid runtime binding. + +Recommended UI-to-API mapping: + +```text +CSGClaw Agent UI + Create Agent for CSGClaw IM -> POST /api/v1/channels/csgclaw/participants + type=agent, agent_binding.mode=create + Edit runtime/model/profile -> PATCH/PUT /api/v1/agents/{id}... + Start/stop/logs -> /api/v1/agents/{id}/... + +Channel or room page + Create new agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=create + Add existing agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=reuse + Add real person -> POST /api/v1/channels/{channel}/participants + type=human, agent_binding.mode=none +``` + +### Message and Mention APIs + +For participant-scoped send APIs, the sender comes from the path participant and +the body does not include `sender_id`. Mentions are arrays so one message can +mention multiple participants. + +```json +{ + "room_id": "oc_xxx", + "mentions": [ + { + "id": "human-alice" + } + ], + "content": "please take a look" +} +``` + +The channel adapter resolves: + +```text +path id -> Participant(channel=feishu, id=u-test) + -> channel_user_ref/channel_app_ref for sender credentials + +mentions[].id -> Participant(channel=feishu, id=human-alice) + -> channel_user_ref=open_id +``` + +For CSGClaw IM, the adapter renders a local mention using the local user ID. For +Feishu, the adapter renders `name`. For future IMs, +the adapter uses that channel's mention syntax. + +If a channel-level message endpoint remains, `sender_id` must explicitly mean a +participant ID in that channel. It must not mean bot ID. A single `mention_id` +does not belong in the new API and should only exist as a temporary +implementation detail while callers are updated in the same change. + +Room member APIs should also move from user-shaped fields to participant-shaped +fields. For example, `user_ids` should become `participant_ids` or +`participants: ParticipantRef[]`. Matrix payload adapters may still emit or +accept Matrix-native `user_id` values at the protocol boundary, but the +CSGClaw domain model should resolve them to participants before room membership +or mention logic runs. + +## UI Plan + +The UI should not force users to choose between model terms such as +participant, agent, and channel user as the first decision. + +Use intent-based entry points: + +```text +Add Bot to Current Channel + + Create a new bot + - create Participant(type=agent) + - create and bind a new Agent + - configure runtime, model, and template + + Add an existing bot + - list agents that are not yet represented in this channel + - create Participant(type=agent) + - bind it to the selected Agent + - confirm channel-specific identity settings +``` + +For human participants, use channel-native wording: + +```text +Add Person + - choose or enter channel user identity + - create Participant(type=human) + - make the person mentionable and optionally add them to a room +``` + +The UI can still use product-friendly labels such as Bot and Person. The backend +should keep the participant and agent split explicit. + +## One-Step Implementation Scope + +- Add participant request/response types. +- Add participant storage with canonical key `(channel, id)`. +- Replace the public `User` API with participant APIs. Keep only an internal + channel identity/profile store where the CSGClaw and external channel + adapters need one. +- Add participant service methods for list, get, create, patch, delete, and + agent binding. +- Register `/api/v1/channels/{channel}/participants`. +- Register participant event and message routes: + `/api/v1/channels/{channel}/participants/{id}/events` and + `/api/v1/channels/{channel}/participants/{id}/messages`. +- Register the participant notification route: + `/api/v1/channels/{channel}/participants/{id}/notifications`. +- Register agent LLM routes under `/api/v1/agents/{agent_id}/llm/*`. +- Implement create modes: `create`, `reuse`, and `none`. +- Add response expansion for `include_agent` and `include_channel_user`. +- Add tests for creating a Feishu participant `u-test` bound to agent `u-qa`. +- Resolve sender and mention IDs through participant service. +- Update CSGClaw IM mention rendering to accept human and agent participants. +- Update Feishu send path so mentions resolve to participant `channel_user_ref` + instead of requiring a configured bot app for every mention. +- Add tests for agent-to-human mentions in CSGClaw IM and Feishu. +- Add tests proving a Feishu human participant can be mentioned with + `channel_app_ref + open_id` without having its own bot app/config. +- Change message send requests from a single `mention_id` to `mentions` / + `mention_ids` arrays. +- Rename room membership request fields from `user_ids` to `participant_ids` or + structured participant refs. +- Add `participants` expansion to `GET /api/v1/agents`. +- Include participants from Feishu and future channel stores. +- Add tests proving that agent `u-qa` appears with both `csgclaw:u-qa` and + `feishu:u-test` participant bindings. +- Add tests proving that deleting the `feishu:u-test` participant does not delete + agent `u-qa` while `csgclaw:u-qa` still references it. +- Replace the current create-agent/create-bot ambiguity with intent-based + channel actions. +- Make CSGClaw UI create chat-capable agents through + `POST /api/v1/channels/csgclaw/participants`, not direct agent creation plus + separate user provisioning. +- Add "Create a new bot" and "Add an existing bot" flows. +- Add "Add Person" flow for human participants. +- Replace the current notification bot webhook/pull routes with a + participant-scoped notification endpoint. +- Keep existing Agent pages focused on runtime configuration and lifecycle. +- Do not implement chat history sync, sync storage, or sync APIs in this change; + only keep the identity model compatible with future sync. +- Delete channel bot CRUD, Feishu bot event, `/api/bots/*`, and public `/users` + routes in the same change set. +- Replace runtime bridge callers with participant/agent-scoped routes before + removing the old handlers. + +## Why This Solves the Problem Best + +This model matches the real domain boundaries. People and bots are channel +identities. Agents are reusable runtime capabilities. Mentions belong to the +channel identity layer, not the runtime layer. + +It also removes the ID-equality assumption. A Feishu participant can be named +`u-test` and bind to underlying agent `u-qa` explicitly. The relationship is a +stored foreign key rather than a naming convention. + +The result is a single target API rather than two competing surfaces. UI code no +longer calls bot APIs when the user is creating an Agent, and channel workflows +create participants explicitly. The UI can stay simple by presenting user intent +instead of internal model terminology. diff --git a/docs/participant-architecture.zh.md b/docs/participant-architecture.zh.md new file mode 100644 index 00000000..e999cd39 --- /dev/null +++ b/docs/participant-architecture.zh.md @@ -0,0 +1,646 @@ +# Participant 身份架构与 API 修改计划 + +## 快速 Review 要点 + +- 核心变化是把 `Participant`、`Agent`、`ChannelUser` 和产品文案里的 `Bot` 拆开:Participant 是 room/message/member/mention 使用的协作身份;Agent 是 runtime 执行实体;ChannelUser 是 channel 内部 identity/profile;Bot 不再是后端 API 和存储模型。 +- UI 创建一个能出现在 CSGClaw 自带 IM 里的 Agent 时,应调用 `POST /api/v1/channels/csgclaw/participants`,使用 `type=agent` 和 `agent_binding.mode=create`,由服务端一次性创建 Agent、ChannelUser 和 Participant。 +- 跨 channel 复用不再依赖 ID 相等。同一个 Agent 可以有多个 participant,例如 `csgclaw:u-qa -> agent:u-qa` 和 `feishu:u-test -> agent:u-qa`。 +- Mention 走 participant 身份层。Feishu 真人 mention 使用 `channel_user_ref` 加 `channel_app_ref` / `channel_user_kind` 显式解析,不要求真人拥有 bot app/config。 +- Notification 是 `type=notification` 的 participant,表示 webhook、系统事件或 pull relay 这类通知来源;默认不绑定 Agent,也不暴露 LLM bridge。 +- Message 新 API 使用 `mentions` / `mention_ids` 数组;room membership 从 `user_ids` 迁移到 `participant_ids` 或结构化 `ParticipantRef`。 +- 删除 participant 默认只删除 channel 身份绑定,不删除底层 Agent;只有显式 `delete_agent=if_unreferenced` 这类语义才允许清理未被引用的 Agent。 +- 这次是前后端、runtime bridge 和内置模板同步更新的 breaking API 调整:旧 bot 路由、公开 `/users` 路由不保留兼容别名。 +- Matrix 对齐只覆盖本次涉及的 identity、membership、message、mention 和 thread 形状,不实现完整 Matrix homeserver 或 Client-Server API。 + +## 背景 + +CSGClaw 目前的 `bot`、`agent` 和 channel `user` 概念在最早的本地多 Agent 协作里可以工作,但面对多渠道、多真人、多机器人协作时边界不够清楚。 + +最直接的问题是 mention 身份。Agent 需要在 CSGClaw 自带 IM、飞书以及未来其他 IM 里 @ 真人。当前 bot 模型默认消息发送者和 mention 对象都是 bot 类身份。在飞书里,这会导致 mention 通过已配置 bot app 身份解析,Agent 无法自然地 @ 一个真人 open_id。 + +还有一个命名和归属问题。UI 里很多操作叫 Agent 管理,但部分客户端实际调用 channel bot API。与此同时,底层 runtime agent 本来就可以跨 channel 复用。例如,Feishu bot 可以建模为“飞书 channel 身份 + 一个原本从 CSGClaw channel 创建的底层 Agent”。现在这种复用主要依赖 `u-manager` 这类相同 ID 约定。如果 CSGClaw 里的 agent 是 `u-qa`,但飞书侧 bot 叫 `u-test`,这个关系就无法清晰表达。 + +目标设计是拆开这些概念: + +- `Participant` 是协作身份,被 room、message、member、mention 引用。 +- `Agent` 是 runtime 执行实体,拥有 model、profile、生命周期、日志和 sandbox 状态。 +- `Bot` 从 API 和存储模型中移除。只有当产品文案明确需要面向用户说“Bot”时,UI 才可以保留这个词。 + +## 目标 + +- Agent 可以在 CSGClaw IM、飞书和未来 IM 中 @ 真人。 +- Room 和 Message 可以统一包含 human、agent-backed participant、notification participant。 +- 一个底层 Agent 可以被多个 channel participant 复用,不依赖 ID 相等。 +- `GET /api/v1/agents` 可以展示所有 runtime agent,以及这些 agent 在任意支持 IM 中对应的 participant,包括飞书。 +- UI 可以同时支持创建新的 agent-backed participant,以及把已有 agent 添加到另一个 channel,而不要求用户先理解内部模型。 +- 前后端、runtime bridge 和内置模板同步更新,现有 bot 路由不保留旧别名,直接删除。 +- 新模型在这次涉及 room、member、message、mention、thread 的范围内尽量贴近 Matrix 语义。 + +## 非目标 + +- 本次不合并同一个真人在不同 channel 的身份。如果后续产品需要跨 channel 审计或权限,可以再增加 `Identity` 层。 +- 不要求所有 participant 都有 agent。human 和 notification participant 默认不需要 runtime 执行能力。 +- 本次不实现完整 Matrix homeserver 或完整 Client-Server API。只在这次会碰到的身份、成员、消息、mention 和 thread 形状上对齐 Matrix。 + +## 目标模型 + +### Agent + +Agent 是 CSGClaw 全局 runtime 实体,不属于任何单一 channel。 + +```text +Agent + id + name + role + runtime_id + runtime_kind + image + runtime_options + status + agent_profile + created_at +``` + +规则: + +- Agent ID 全局唯一。 +- Agent 生命周期操作继续放在 `/api/v1/agents`。 +- Agent profile、model、runtime、日志、start、stop、restart、recreate 仍由 agent service 管理。 + +### Participant + +Participant 是某个 channel 内可见的协作身份。 + +```text +Participant + id # 在所在 channel 内稳定 + channel # csgclaw | feishu | matrix | ... + type # human | agent | notification + name + avatar + channel_user_ref # csgclaw user id, feishu open_id, matrix user_id, ... + channel_user_kind # local_user_id | open_id | matrix_user_id | ... + channel_app_ref # 可选,用于 Feishu app_id 这类 bot app/config 身份 + agent_id # 可选 FK -> Agent.id,仅 type=agent 时有意义 + lifecycle_status # provisioning | active | disabled | failed + presence # 可选,channel presence 或 room member view + mentionable + metadata + created_at + updated_at +``` + +规则: + +- Participant 的规范 key 是 `(channel, id)`。 +- 只要 participant 需要在 channel 里发送、接收或被 mention,`channel_user_ref` 就是必填。 +- 活跃 participant 应满足该 channel 的 channel-user identity 唯一。对简单 channel 是 `(channel, channel_user_ref)`;对 Feishu 这类 app-scoped identity,需要把 `channel_app_ref` 和 `channel_user_kind` 纳入唯一键。 +- `lifecycle_status` 描述 participant 记录自身是否可用;`presence` 描述 channel 或 room 里的在线/离线视图;runtime 是否 running 应来自绑定的 Agent,不应写入 participant 的持久状态。 +- `mentionable=false` 表示该 participant 可以存在于列表或历史中,但不应被新消息 mention。 +- `type=human` 不要求也不应该要求 `agent_id`。 +- `type=notification` 不要求也不应该要求 `agent_id`。 +- `type=agent` 可以绑定已有 agent、创建新 agent,或者先注册 participant 后续再绑定 runtime。 +- 一个 Agent 可以跨 channel 拥有多个 participant: + +```text +csgclaw:u-qa -> agent:u-qa +feishu:u-test -> agent:u-qa +matrix:qa-bot -> agent:u-qa +``` + +### Channel User / Channel Identity + +引入 Participant 之后,现有对外 `User` 模型不应该继续作为顶层产品 API 保留。对外创建、查询、更新和删除“人”的入口应统一替换成 participant API。 + +但底层仍然需要一个类似 user 的记录,只是它的语义变成 channel-scoped identity/profile,由 channel adapter 拥有,不再是主要协作身份。 + +```text +ChannelUser + channel # csgclaw | feishu | matrix | ... + ref # CSGClaw local user id, Feishu open_id/union_id, Matrix user_id + kind # local_user_id | open_id | matrix_user_id | ... + app_ref # 可选,Feishu app_id/tenant scoped identity 时使用 + display_name + handle + avatar_url + presence + raw_profile + updated_at +``` + +规则: + +- `Participant.channel_user_ref` 指向 channel user,或该 channel 原生的等价身份。 +- 对内置 CSGClaw channel,这个内部记录替代当前 `User` 存储形状,用来承载 profile、avatar、handle 和 presence。 +- 对飞书,这个记录由 adapter 管理。`kind=open_id` 时 `ref` 是 app-scoped open_id,必须和 `app_ref` 一起理解;如果后续使用 `union_id` 或 `user_id`,也必须在 `kind` 中显式标出。 +- 对 Matrix,这个记录可以自然映射到 Matrix user ID,以及 `displayname`、`avatar_url`、membership state 等 member profile 字段。 +- 公共客户端应通过 participant 创建、列出、更新和删除真人,不再调用独立 `/users` API。 + +### Feishu 身份 Scope + +Feishu 的 user identity 不能只看一个裸 `open_id` 字符串。不同 app、tenant 或 identity type 下,同一个真人可能有不同 ID,同一个 ID 字符串也不能跨 scope 复用。 + +规则: + +- `channel_app_ref` 表示这个 participant 对应的 Feishu app/config,例如 `cli_xxx`。 +- `channel_user_kind=open_id` 时,`channel_user_ref` 是该 app scope 下的 open_id;唯一键应至少包含 `(channel, channel_app_ref, channel_user_kind, channel_user_ref)`。 +- `type=agent` participant 需要 `channel_app_ref` 来确定发送消息时使用哪个 Feishu app 凭证。 +- `type=human` participant 用 `channel_user_ref` 作为 mention 目标,不要求这个真人有 bot app/config。 +- 如果 adapter 从 Feishu event 里只拿到 `user_id`、`union_id` 或其他身份,应先按 `channel_user_kind` 记录原始身份,再在需要发送或 mention 时做显式解析,不能隐式当作 bot ID。 + +### Notification Participant + +Notification 也是一种 channel participant,但它默认不是 runtime agent。 + +```text +Participant(type=notification) + channel_user_ref # notification sender identity 或本地 webhook identity + channel_app_ref # 可选,外部 channel 发送所需 app/config + metadata.notification # webhook、remote_pull、subscription、delivery config +``` + +规则: + +- Notification participant 可以作为 room/message sender,用于展示系统通知、第三方 webhook 或 pull relay 的来源。 +- Notification participant 默认没有 `agent_id`,也不暴露 LLM bridge。 +- Notification webhook/pull 配置挂在 participant metadata 或专门的 notification profile 上,不再挂在 bot API 形状上。 +- 原 `POST /api/v1/channels/csgclaw/bots/{id}/notifications` 应替换为 participant-scoped notification endpoint: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +### Room、Message、Mention + +Room 和 Message 应引用 participant,而不是引用 agent。 + +```text +Room + channel + members: ParticipantRef[] + +Message + event_id + room_id + sender: ParticipantRef + mentions: ParticipantRef[] + content + relates_to + +ParticipantRef + channel + id +``` + +新 API 应避免 bot 形状命名。`sender_id`、`member_ids`、`user_ids` 这类字段只有在含义明确是 participant 或 Matrix user identity 时才保留,不能继续表示 legacy bot identity。新消息 API 应优先使用 `mentions: ParticipantRef[]` 或 `mention_ids: []`,而不是单个 `mention_id`。存在歧义时,优先使用 `participant_id`、`participant_ids` 或结构化 `ParticipantRef`。 + +### 未来聊天记录同步兼容性 + +聊天记录同步不是这次实施计划的一部分,它只是 participant 模型必须兼容的后续方向。 + +如果用户在飞书里聊天,CSGClaw 后续同步这个 room,导入消息应能走和本地消息相同的 participant 解析路径。因此 participant 模型不能假设所有消息都是本地产生,也不能假设所有 sender 都是 agent-backed participant。 + +未来 sync 工作可以增加 `MessageEvent` 或相邻 sync 记录,字段包括: + +- `external_room_ref`,例如 Feishu `chat_id` 或 Matrix room ID; +- `external_event_id`,例如 Feishu `message_id` 或 Matrix event ID; +- source IM 提供的 `origin_server_ts`; +- 本地摄入时间 `received_at`; +- 用于可恢复同步的 `sync_batch` 或 cursor metadata; +- 脱敏或限长后的 `raw_event`,用于排查问题。 + +这次计划不实现 sync storage、sync API 或 backfill job。这里只保证 sender、mention、room 和 event identity 不阻碍后续同步。 + +### 可选的未来 Identity 层 + +如果未来需要知道同一个真人同时出现在 CSGClaw local user、Feishu open_id 和 Matrix user_id 中,可以增加身份聚合层: + +```text +Identity 1 -> N Participant(type=human) +``` + +这个层不是解决 Agent @ 真人或跨 channel 复用 Agent 的前置条件。 + +## Matrix 对齐 + +后续 CSGClaw IM 会往 Matrix 协议方向实现,因此这次 participant 调整应该在涉及范围内尽量贴近 Matrix。目标不是现在实现完整 Matrix Client-Server API,而是避免产生第二套不可兼容的 IM 模型。 + +对齐点如下: + +- Matrix user ID 映射到 `Participant.channel_user_ref`,`channel_user_kind=matrix_user_id`,例如 `@qa:example.org`。 +- Matrix room ID 映射到 channel room reference,例如 `!roomid:example.org`;room alias 可以放在 channel metadata。 +- Room membership 应能表示成 `m.room.member` state:`membership`、`displayname`、`avatar_url` 属于 room membership 或 participant view,不属于 runtime agent。 +- 文本消息应能表示成 `m.room.message` event,content 至少包含 `msgtype=m.text`、`body`,可选 `format` / `formatted_body`。 +- Mention 应能表示成 Matrix `m.mentions`,尤其是用户 mention 的 `m.mentions.user_ids` 和 room mention 的 `m.mentions.room`。 +- 已有 thread metadata 应继续保持 Matrix 形状:`m.relates_to.rel_type=m.thread` 加 root event ID。 +- 后续 CSGClaw sync API 应保持与 Matrix `/sync` 类似的高层形状:客户端拿到 joined room timelines,以及类似 `next_batch` / `since` 的可恢复 batch token。 + +这样 CSGClaw 自有 IM 后续迁到 Matrix 会更顺,Feishu 和其他 channel adapter 仍然可以保留各自原生传输细节。 + +## API 计划 + +### Participant API + +在 channel 命名空间下新增 participant API。这些 API 替换旧的 channel bot CRUD 路由。 + +```text +GET /api/v1/channels/{channel}/participants +POST /api/v1/channels/{channel}/participants +GET /api/v1/channels/{channel}/participants/{id} +PATCH /api/v1/channels/{channel}/participants/{id} +DELETE /api/v1/channels/{channel}/participants/{id} +``` + +以下路由直接删除: + +```text +GET /api/v1/channels/{channel}/bots +POST /api/v1/channels/{channel}/bots +GET /api/v1/channels/{channel}/bots/{id} +PATCH /api/v1/channels/{channel}/bots/{id} +DELETE /api/v1/channels/{channel}/bots/{id} +GET /api/v1/channels/feishu/bots/{id}/events +POST /api/v1/channels/csgclaw/bots/{id}/notifications +GET /api/bots/{id}/events +POST /api/bots/{id}/messages/send +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +当前公开 user 路由也应从产品 API 面删除: + +```text +GET /api/v1/users +POST /api/v1/users +DELETE /api/v1/users/{id} +GET /api/v1/channels/csgclaw/users +POST /api/v1/channels/csgclaw/users +DELETE /api/v1/channels/csgclaw/users/{id} +``` + +使用 participant API 替代: + +```text +GET /api/v1/channels/{channel}/participants?type=human +POST /api/v1/channels/{channel}/participants +``` + +列表查询参数: + +- `type=human|agent|notification` +- `agent_id=` +- `include_agent=true` +- `include_channel_user=true` + +创建一个同时新建 Agent 的 agent-backed participant: + +```json +{ + "id": "u-qa", + "type": "agent", + "name": "qa", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "agent_profile": { + "provider": "api", + "model_id": "gpt-5.4" + } + } + } +} +``` + +这个 endpoint 是旧 UI “create bot” 行为的替代:旧行为会同时创建 agent 和 channel user。新方案必须由服务端提供一个单次 provisioning 操作,不能让 UI 先调用 `POST /api/v1/agents` 再单独创建 participant。 + +对于内置 CSGClaw channel,`agent_binding.mode=create` 必须一次性创建: + +```text +1. Agent +2. Channel user / Matrix-shaped member identity +3. Participant(type=agent, agent_id=) +``` + +只有三个资源都有效时才算提交成功。如果 agent 已创建但 user 或 participant 创建失败,服务端必须回滚已创建 agent,或者把该操作标记为失败且可通过 idempotency key 安全重试。 + +对于外部 channel,不存在真正的分布式事务。UI 仍然只调用一次 API,但服务端必须通过幂等和补偿保证一致性: + +- 接受可选 `request_id` 或 `client_transaction_id`; +- 同一个 key 的重复请求返回同一个最终资源; +- 记录已完成的部分步骤; +- 在 participant 暴露为 active 前,重试或补偿失败的 channel-user provisioning。 + +创建一个复用已有 Agent 的 channel participant: + +```json +{ + "id": "u-test", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "channel_app_ref": "cli_xxx", + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } +} +``` + +创建一个真人 participant: + +```json +{ + "id": "human-alice", + "type": "human", + "name": "Alice", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } +} +``` + +支持的 `agent_binding.mode`: + +- `create`:创建新的 Agent 并绑定到这个 participant。 +- `reuse`:绑定到已有 Agent。 +- `none`:只创建 participant,不绑定 runtime。适用于 human、notification 和草稿状态的 agent participant。 + +校验规则: + +- `type=human` 拒绝 `agent_binding.mode=create`。 +- `type=notification` 拒绝 `agent_binding.mode=create`,除非未来 notification 明确需要 runtime。 +- `type=agent` 且 `mode=reuse` 时必须提供 `agent_id`。 +- `type=agent` 且 `mode=create` 时必须提供足够创建合法 worker 或 manager 的 Agent 字段。 +- Participant ID 和 Agent ID 不要求相同。 + +删除规则: + +- `DELETE /api/v1/channels/{channel}/participants/{id}` 默认只删除 participant 以及它的 channel 绑定,不删除底层 Agent。 +- 如果 `type=agent` participant 绑定了 Agent,删除 participant 只解除该 channel identity 与 Agent 的关联;同一个 Agent 的其他 participant 不受影响。 +- 如果调用方希望同时清理不再被引用的 Agent,应使用显式参数,例如 `delete_agent=if_unreferenced`。当仍有其他 participant 引用该 Agent 时,服务端必须拒绝删除 Agent。 +- Channel user / channel identity 只有在由 CSGClaw 管理且没有其他 active participant 引用时才可以被清理;外部 channel 通常只删除本地映射或标记 inactive,不删除远端真实用户。 +- Notification participant 删除时应同时移除本地 notification profile、webhook token 和 remote_pull subscription metadata。 + +### Agent API + +保留 `/api/v1/agents` 作为较底层的 runtime agent API,用于编辑 model/profile、start、stop、restart、recreate、delete、查看日志,以及支持内部 provisioning 流程。 + +CSGClaw 自己的产品 UI 如果创建结果预期会出现在 CSGClaw IM 中,不应把 `POST /api/v1/agents` 作为主“创建 Agent”入口。它应和飞书等第三方 IM 使用同一套 participant provisioning API,只是 `channel=csgclaw`。 + +扩展 list/detail 响应,展示 participant 绑定关系。 + +```text +GET /api/v1/agents?include_participants=true +GET /api/v1/agents/{id}?include_participants=true +``` + +响应片段示例: + +```json +{ + "id": "u-qa", + "name": "qa", + "role": "worker", + "runtime_kind": "codex", + "status": "running", + "participants": [ + { + "id": "u-qa", + "channel": "csgclaw", + "type": "agent", + "channel_user_ref": "u-qa" + }, + { + "id": "u-test", + "channel": "feishu", + "type": "agent", + "channel_user_ref": "ou_xxx" + } + ] +} +``` + +这满足“agent list 要包含任意 IM 中的 agent,包括当前支持的第三方 IM 飞书 agent”的需求。 + +### Runtime Bridge API 替换 + +删除 `/api/bots/*` 后,需要把每个旧 bridge surface 替换成明确的 participant scoped 或 agent scoped 路由。 + +Participant event stream 属于 channel 身份: + +```text +GET /api/v1/channels/{channel}/participants/{id}/events +``` + +替换: + +```text +GET /api/v1/channels/feishu/bots/{id}/events +GET /api/bots/{id}/events +``` + +Participant 作为发送者发消息也属于 channel 身份: + +```text +POST /api/v1/channels/{channel}/participants/{id}/messages +``` + +发送者来自 path participant。body 包含 `room_id`、`content`、可选 `mentions` 和可选 thread relation 字段。替换: + +```text +POST /api/bots/{id}/messages/send +``` + +Notification 投递也属于 channel 身份: + +```text +POST /api/v1/channels/{channel}/participants/{id}/notifications +``` + +替换: + +```text +POST /api/v1/channels/csgclaw/bots/{id}/notifications +``` + +LLM bridge 属于 runtime agent,不属于 channel 身份: + +```text +GET /api/v1/agents/{agent_id}/llm/models +POST /api/v1/agents/{agent_id}/llm/chat/completions +POST /api/v1/agents/{agent_id}/llm/responses +``` + +替换: + +```text +GET /api/bots/{id}/llm/models +GET /api/bots/{id}/llm/v1/models +POST /api/bots/{id}/llm/chat/completions +POST /api/bots/{id}/llm/v1/chat/completions +POST /api/bots/{id}/llm/responses +POST /api/bots/{id}/llm/v1/responses +``` + +如果 runtime 只知道自己的 channel participant ID,需要先解析 participant,再用 `agent_id` 调 LLM API。这样消息身份和 model/runtime 身份保持分离。 + +### UI API 替换 + +当前 UI 问题是“创建 Agent”的流程调用了将被删除的 channel bot API。替代方案不是让 UI 串联多个 API。CSGClaw 自己的 UI 也要和飞书及未来第三方 IM 使用同一个模型:在目标 channel 创建 participant,并绑定或创建底层 agent。 + +当 CSGClaw UI 行为是“创建一个可以在 CSGClaw IM 里聊天的 Agent”时,调用: + +```text +POST /api/v1/channels/csgclaw/participants +``` + +使用 `type=agent` 和 `agent_binding.mode=create` 一次性创建 runtime agent 和 CSGClaw participant。这就是之前 bot API “同时创建 agent 和 user”的直接替代。 + +当 UI 要把已有 agent 添加到另一个 channel 时,仍使用目标 channel 的同一个 endpoint,并传 `agent_binding.mode=reuse`。 + +这个流程里 UI 不能先调用 `POST /api/v1/agents`,再单独创建 user 或 participant。拆成多次调用会产生半失败:agent 创建成功但没有 channel identity,或者 channel identity 存在但没有有效 runtime binding。 + +推荐 UI 到 API 映射: + +```text +CSGClaw Agent UI + 创建 CSGClaw IM Agent -> POST /api/v1/channels/csgclaw/participants + type=agent, agent_binding.mode=create + 编辑 runtime/model/profile -> PATCH/PUT /api/v1/agents/{id}... + start/stop/logs -> /api/v1/agents/{id}/... + +Channel 或 Room 页面 + 在当前 channel 创建新 Agent identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=create + 从已有 Agent 添加到 channel identity -> POST /api/v1/channels/{channel}/participants + type=agent, agent_binding.mode=reuse + 添加真人 -> POST /api/v1/channels/{channel}/participants + type=human, agent_binding.mode=none +``` + +### Message 和 Mention API + +对 participant-scoped 发送 API,发送者来自 path participant,body 中不再传 `sender_id`。Mention 使用数组,支持一次消息 mention 多个 participant。 + +```json +{ + "room_id": "oc_xxx", + "mentions": [ + { + "id": "human-alice" + } + ], + "content": "please take a look" +} +``` + +Channel adapter 解析链路: + +```text +path id -> Participant(channel=feishu, id=u-test) + -> sender 所需的 channel_user_ref/channel_app_ref + +mentions[].id -> Participant(channel=feishu, id=human-alice) + -> channel_user_ref=open_id +``` + +在 CSGClaw IM 中,adapter 渲染本地 mention。在飞书中,adapter 渲染 `name`。未来 IM 使用各自 channel 的 mention 语法。 + +如果仍保留 channel-level message endpoint,`sender_id` 必须明确解释为该 channel 下的 participant ID,不能再解释为 bot ID。单个 `mention_id` 不进入新 API,只能作为旧请求结构在调用方同步替换前的临时实现细节。 + +Room member API 也应从 user 形状迁移到 participant 形状。例如,`user_ids` 应改为 `participant_ids` 或 `participants: ParticipantRef[]`。Matrix 协议边界的 adapter 仍可发送或接收 Matrix 原生 `user_id`,但进入 CSGClaw 领域模型前,应先解析成 participant,再进入 room membership 或 mention 逻辑。 + +## UI 计划 + +UI 不应该把 participant、agent、channel user 这些内部模型词作为用户第一层选择。 + +使用基于意图的入口: + +```text +添加 Bot 到当前 Channel + + 创建全新的 Bot + - 创建 Participant(type=agent) + - 创建并绑定新的 Agent + - 配置 runtime、model、template + + 从已有 Bot 添加 + - 列出当前 channel 中还没有出现的 agent + - 创建 Participant(type=agent) + - 绑定到选中的 Agent + - 确认 channel 相关身份设置 +``` + +真人 participant 使用 channel 语义: + +```text +添加真人 + - 选择或输入 channel user identity + - 创建 Participant(type=human) + - 让真人可被 mention,并可选加入 room +``` + +UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 participant 和 agent 的分层清晰。 + +## 一步到位实施范围 + +- 新增 participant request/response types。 +- 新增 participant storage,规范 key 为 `(channel, id)`。 +- 用 participant API 替换公开 `User` API。只有 CSGClaw 和外部 channel adapter 需要时,才保留内部 channel identity/profile store。 +- 新增 participant service,支持 list、get、create、patch、delete 和 agent binding。 +- 注册 `/api/v1/channels/{channel}/participants`。 +- 注册 participant event/message 路由: + `/api/v1/channels/{channel}/participants/{id}/events` 和 + `/api/v1/channels/{channel}/participants/{id}/messages`。 +- 注册 participant notification 路由: + `/api/v1/channels/{channel}/participants/{id}/notifications`。 +- 在 `/api/v1/agents/{agent_id}/llm/*` 下注册 agent LLM 路由。 +- 实现 `create`、`reuse`、`none` 三种创建模式。 +- 支持 `include_agent` 和 `include_channel_user` 响应展开。 +- 增加测试:创建 Feishu participant `u-test` 并绑定到 agent `u-qa`。 +- sender 和 mention ID 统一通过 participant service 解析。 +- 更新 CSGClaw IM mention 渲染,支持 human 和 agent participant。 +- 更新 Feishu 发送链路,让 mention 解析到 participant 的 `channel_user_ref`,不再要求每个 mention 对象都有配置好的 bot app。 +- 增加测试:Agent 在 CSGClaw IM 和飞书中 @ 真人。 +- 增加测试:Feishu human participant 使用 `channel_app_ref + open_id` 作为 mention 目标,不需要自己的 bot app/config。 +- 将 message send 请求从单个 `mention_id` 调整为 `mentions` / `mention_ids` 数组。 +- 将 room membership 请求字段从 `user_ids` 改为 `participant_ids` 或结构化 participant ref。 +- 为 `GET /api/v1/agents` 增加 `participants` 展开。 +- 包含飞书和未来 channel store 中的 participant。 +- 增加测试:agent `u-qa` 同时展示 `csgclaw:u-qa` 和 `feishu:u-test` 两个绑定。 +- 增加测试:删除 `feishu:u-test` participant 不删除仍被 `csgclaw:u-qa` 使用的 agent `u-qa`。 +- 用基于意图的 channel action 替代当前 create-agent/create-bot 混淆。 +- CSGClaw UI 创建可聊天 Agent 时,使用 + `POST /api/v1/channels/csgclaw/participants`,不要直接创建 agent 后再单独创建 user。 +- 增加“创建全新的 Bot”和“从已有 Bot 添加”流程。 +- 增加“添加真人”流程。 +- 用 participant-scoped notification endpoint 替换当前 notification bot webhook/pull 路由。 +- Agent 页面聚焦 runtime 配置和生命周期。 +- 本次不实现聊天记录同步、sync storage 或 sync API;只保证身份模型兼容未来同步。 +- 在同一个变更中删除 channel bot CRUD、Feishu bot event、`/api/bots/*` 和公开 `/users` 路由。 +- 删除旧 handler 前,先把 runtime bridge 调用方替换成 participant/agent scoped 路由。 + +## 为什么这是最佳方案 + +这个模型符合真实领域边界。真人和 bot 是 channel 身份,Agent 是可复用的 runtime 能力。Mention 属于 channel 身份层,不属于 runtime 层。 + +它也去掉了 ID 相等假设。Feishu participant 可以叫 `u-test`,同时显式绑定到底层 agent `u-qa`。这个关系是持久化外键,不再是命名约定。 + +最终结果是一个目标 API,而不是两个并存的 API 面。UI 创建 Agent 时不再调用 bot API;channel 流程显式创建 participant。UI 仍然可以用用户意图组织流程,而不是暴露内部模型术语。 From 3a54e7728b4c67ad4ee7e1217a1a29f9fa07e42d Mon Sep 17 00:00:00 2001 From: Yun Long Date: Tue, 2 Jun 2026 11:29:11 +0800 Subject: [PATCH 2/6] Docs: Add immigration plans & cli design --- docs/participant-architecture.md | 187 ++++++++++++++++++++++++---- docs/participant-architecture.zh.md | 105 +++++++++++++--- 2 files changed, 253 insertions(+), 39 deletions(-) diff --git a/docs/participant-architecture.md b/docs/participant-architecture.md index 6792ae10..f26ede37 100644 --- a/docs/participant-architecture.md +++ b/docs/participant-architecture.md @@ -11,9 +11,12 @@ should call `POST /api/v1/channels/csgclaw/participants` with `type=agent` and `agent_binding.mode=create`. The server provisions the Agent, ChannelUser, and Participant in one operation. +- For a newly created agent-backed participant, the generated Agent ID keeps the + old relationship: `agent_id = u-{participant_id}`. The participant ID should + come from an explicit `id` or stable key, not from a later-editable `name`. - Cross-channel reuse no longer depends on equal IDs. One Agent can have many - participants, such as `csgclaw:u-qa -> agent:u-qa` and - `feishu:u-test -> agent:u-qa`. + participants, such as `csgclaw:qa -> agent:u-qa` and + `feishu:test -> agent:u-qa`. - Mentions belong to the participant identity layer. Feishu human mentions resolve through `channel_user_ref` plus `channel_app_ref` / `channel_user_kind`; humans do not need their own bot app/config. @@ -26,12 +29,42 @@ not the underlying Agent. Agent cleanup requires explicit semantics such as `delete_agent=if_unreferenced`. - This is a coordinated breaking API change across frontend, backend, runtime - bridge, and embedded templates: old bot routes and public `/users` routes do - not keep compatibility aliases. + bridge, CLI, and embedded templates. API compatibility is not the migration + focus; old bot routes and public `/users` routes do not keep compatibility + aliases. +- Legacy on-disk data such as `bots.json` should still be migratable. If an old + runtime image or template contract is outdated, show a recreate warning in the + UI; the current recreate flow only promises to preserve user-installed skills. - Matrix alignment is limited to identity, membership, message, mention, and thread shapes touched by this work. It does not implement a full Matrix homeserver or Client-Server API. +## Migration Priorities + +Frontend and backend ship together, so API breaking changes are not the +migration risk. Migration focuses on local on-disk state and old runtime images. + +- **`bots.json`**: legacy bot records must still load and migrate into + participant records. Normal bots become `type=agent` participants while + preserving the original `agent_id` / `channel_user_ref`; notification bots + become `type=notification` participants. +- **IM state**: identity references in `im/state.json` and `im/sessions/*.jsonl` + must migrate from old user/bot IDs to participant IDs, including `users`, room + `members`, message `sender_id`, `mentions`, and thread context. +- **Feishu config**: app/config entries in `channels/feishu.toml` currently keyed + by old `bot_id` must migrate to participant/channel-app semantics so Feishu + sending and mention resolution do not lose configuration. +- **Team state**: `teams/*` fields such as `lead_bot_id`, `member_bot_ids`, + `bot_id`, `actor_id`, `created_by`, `assigned_to`, `requested_by`, and + `approver_id` must migrate to participant IDs. +- **Agents state**: `agents/state.json` does not need Agent ID rewrites; new + participant records keep pointing at existing Agents through `agent_id`. +- **Outdated image warning**: whenever the runtime image/template contract is + detected as outdated, show a recreate warning in the UI. +- **Recreate preserves skills**: the current recreate flow only promises to + preserve user-installed skills; preserving workspace/project state is not + added in this plan. + ## Background CSGClaw currently uses `bot`, `agent`, and channel `user` concepts in ways that @@ -50,8 +83,8 @@ time, the underlying runtime agent is already reusable across channels. For example, a Feishu bot can be modeled as a Feishu channel identity plus an underlying agent that was originally created from the CSGClaw channel. Today that reuse depends heavily on matching IDs such as `u-manager`. If the CSGClaw -agent is `u-qa` and the Feishu-facing bot is `u-test`, the relationship cannot -be represented cleanly. +agent is `u-qa` and the Feishu-facing participant is `test`, the relationship +cannot be represented cleanly. The target design separates these concerns: @@ -113,6 +146,13 @@ Agent Rules: - Agent IDs are global. +- When an agent-backed participant is created and the request does not specify + an Agent ID, the server generates it as `u-{participant_id}`. This preserves + the old worker/bot ID habit; for example participant `qa` maps to agent + `u-qa`. +- If the caller specifies an Agent ID explicitly, it must still be globally + unique and does not need to match the participant ID. Cross-channel reuse + usually passes an existing `agent_id`. - Agent lifecycle operations remain under `/api/v1/agents`. - Agent profile, model, runtime, logs, start, stop, restart, and recreation stay owned by the agent service. @@ -161,11 +201,54 @@ Rules: - A single agent can have many participants across channels: ```text -csgclaw:u-qa -> agent:u-qa -feishu:u-test -> agent:u-qa +csgclaw:qa -> agent:u-qa +feishu:test -> agent:u-qa matrix:qa-bot -> agent:u-qa ``` +### Participant ID Generation + +Participant IDs are channel identities that users and the CLI see often, so they +should prefer readable and stable values instead of defaulting to bare UUIDs. +UUIDs can remain the internal random source or fallback, but exposing them +directly makes room membership, mentions, and CLI operations harder to read. + +Participant IDs must not be generated from `name`. `name` is a display name and +may become editable later; once an ID is referenced by room membership, messages, +mentions, agent binding, and CLI commands, it must remain stable. The common +industry pattern is "stable slug plus short random collision suffix", such as +Kubernetes object names or many SaaS workspace slugs. Opaque type-prefixed IDs +such as `usr_...` or `agt_...` fit internal-only objects better than +user-operated participant IDs. + +Recommended algorithm: + +1. If the request explicitly passes `id`, normalize it and verify uniqueness. +2. If no `id` is provided, derive a slug only from stable sources such as a + separate `slug` / `handle` field in the create request, an embedded template + key, a role key, an immutable external channel handle, or a legacy bot/user ID + during migration. Do not use editable display `name`. +3. Slug rules: lowercase; trim surrounding whitespace; replace consecutive + non-`[a-z0-9]` characters with `-`; collapse repeated `-`; trim leading and + trailing `-`; keep length between 3 and 48 characters. +4. If the slug is empty, use a readable type prefix plus a short random suffix, + such as `agent-8f3k2m`, `human-8f3k2m`, or `notification-8f3k2m`. +5. If the slug already exists, append a short random suffix, such as + `qa-8f3k2m`. The suffix can be a truncated base32/base36 value derived from + UUID, ULID, or nanoid. +6. The server returns the final participant ID. Repeated requests with the same + `request_id` or `client_transaction_id` must return the same ID. + +For agent-backed participants, the default Agent ID rule remains: + +```text +agent_id = "u-" + participant_id +``` + +For example, creating a CSGClaw IM Agent as participant `qa` generates agent +`u-qa` by default, while the built-in CSGClaw channel user ref can also remain +`u-qa`. This preserves old runtime, workspace, and mention habits. + ### Channel User / Channel Identity The current public `User` model should not remain as a top-level product API @@ -413,7 +496,7 @@ Create an agent-backed participant by creating a new agent: ```json { - "id": "u-qa", + "id": "qa", "type": "agent", "name": "qa", "channel_user": { @@ -468,7 +551,7 @@ Create a channel participant that reuses an existing agent: ```json { - "id": "u-test", + "id": "test", "type": "agent", "name": "QA", "channel_user": { @@ -487,7 +570,7 @@ Create a human participant: ```json { - "id": "human-alice", + "id": "alice", "type": "human", "name": "Alice", "channel_user": { @@ -560,13 +643,13 @@ Example response excerpt: "status": "running", "participants": [ { - "id": "u-qa", + "id": "qa", "channel": "csgclaw", "type": "agent", "channel_user_ref": "u-qa" }, { - "id": "u-test", + "id": "test", "channel": "feishu", "type": "agent", "channel_user_ref": "ou_xxx" @@ -699,7 +782,7 @@ mention multiple participants. "room_id": "oc_xxx", "mentions": [ { - "id": "human-alice" + "id": "alice" } ], "content": "please take a look" @@ -709,10 +792,10 @@ mention multiple participants. The channel adapter resolves: ```text -path id -> Participant(channel=feishu, id=u-test) +path id -> Participant(channel=feishu, id=test) -> channel_user_ref/channel_app_ref for sender credentials -mentions[].id -> Participant(channel=feishu, id=human-alice) +mentions[].id -> Participant(channel=feishu, id=alice) -> channel_user_ref=open_id ``` @@ -766,10 +849,64 @@ Add Person The UI can still use product-friendly labels such as Bot and Person. The backend should keep the participant and agent split explicit. +## CLI Changes + +The canonical CLI resource name should follow the backend model and use +`participant` for collaboration identities, with `pt` as a shorter subcommand +alias. Use `participant` in docs, scripts, and long-lived references; use `pt` +for interactive daily commands. `bot` can remain as a lightweight user-facing +alias for `type=agent` flows, but JSON output, API payloads, and errors should +use participant semantics instead of exposing a Bot storage model. + +Recommended command shape: + +```text +csgclaw participant list --channel csgclaw --type agent +csgclaw participant create --channel csgclaw --type agent --id qa --name QA --bind create +csgclaw participant create --channel feishu --type agent --id test --bind reuse --agent-id u-qa --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant create --channel feishu --type human --id alice --name Alice --channel-user-ref ou_alice --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant delete --channel feishu test +csgclaw participant delete --channel feishu test --delete-agent if-unreferenced +csgclaw pt list --channel csgclaw --type agent +csgclaw pt create --channel csgclaw --type agent --id qa --name QA --bind create +``` + +CLI field renames should match the API: + +- `pt` is an exact short alias for `participant`; every subcommand, flag, output, + and error must behave the same. +- `bot list/create/delete` is no longer canonical. If kept, it should only be a + product alias for `participant --type agent` / `pt --type agent`. +- `agent create` only creates runtime-only Agents. It should not be the primary + entry point for creating a chat-capable CSGClaw IM Agent. +- `user list/create/delete` moves to `participant list/create/delete --type human`. +- Room member commands should rename `--user-id`, `--user-ids`, and + `--member-ids` to `--participant-id`, `--participant-ids`, or structured + participant refs. +- Message commands should replace `--sender-id` with a path participant or an + explicit `--participant-id`; `--mention-id` should become repeatable or be + renamed to `--mention-participant-id` and sent as `mentions` / `mention_ids` + arrays. +- Feishu config commands should replace `--bot-id` with either + `--participant-id` or `--channel-app-ref`, depending on whether the command + configures a participant binding or manages the Feishu app/config. +- Team/task commands should replace `--lead-bot-id`, `--member-bot-ids`, + `--bot-id`, and `--actor-id` with `--lead-participant-id`, + `--member-participant-ids`, `--participant-id`, and + `--actor-participant-id`. Use `--agent-id` only for runtime-specific + operations. +- Runtime-embedded commands such as `csgclaw-cli` must be updated too. Embedded + skills and templates must not keep depending on old `bot_id`, `sender_id`, + `mention_id`, or `user_ids` semantics. + ## One-Step Implementation Scope - Add participant request/response types. - Add participant storage with canonical key `(channel, id)`. +- Add a Participant ID generator: derive readable slugs from explicit `id` or + stable keys, add short random suffixes on collision, never derive IDs from + editable `name`, and keep the default Agent ID for newly created agent-backed + participants as `u-{participant_id}`. - Replace the public `User` API with participant APIs. Keep only an internal channel identity/profile store where the CSGClaw and external channel adapters need one. @@ -784,7 +921,7 @@ should keep the participant and agent split explicit. - Register agent LLM routes under `/api/v1/agents/{agent_id}/llm/*`. - Implement create modes: `create`, `reuse`, and `none`. - Add response expansion for `include_agent` and `include_channel_user`. -- Add tests for creating a Feishu participant `u-test` bound to agent `u-qa`. +- Add tests for creating a Feishu participant `test` bound to agent `u-qa`. - Resolve sender and mention IDs through participant service. - Update CSGClaw IM mention rendering to accept human and agent participants. - Update Feishu send path so mentions resolve to participant `channel_user_ref` @@ -798,10 +935,10 @@ should keep the participant and agent split explicit. structured participant refs. - Add `participants` expansion to `GET /api/v1/agents`. - Include participants from Feishu and future channel stores. -- Add tests proving that agent `u-qa` appears with both `csgclaw:u-qa` and - `feishu:u-test` participant bindings. -- Add tests proving that deleting the `feishu:u-test` participant does not delete - agent `u-qa` while `csgclaw:u-qa` still references it. +- Add tests proving that agent `u-qa` appears with both `csgclaw:qa` and + `feishu:test` participant bindings. +- Add tests proving that deleting the `feishu:test` participant does not delete + agent `u-qa` while `csgclaw:qa` still references it. - Replace the current create-agent/create-bot ambiguity with intent-based channel actions. - Make CSGClaw UI create chat-capable agents through @@ -812,6 +949,12 @@ should keep the participant and agent split explicit. - Replace the current notification bot webhook/pull routes with a participant-scoped notification endpoint. - Keep existing Agent pages focused on runtime configuration and lifecycle. +- Update CLI and `csgclaw-cli`: canonical commands use participant, register the + `pt` short alias, and move old bot/user/member/message parameters to + participant semantics. +- Migrate identity references in legacy `bots.json`, IM state, Feishu config, + and Team state. Warn in the UI when the runtime image/template contract is + outdated; the current recreate flow only preserves user skills. - Do not implement chat history sync, sync storage, or sync APIs in this change; only keep the identity model compatible with future sync. - Delete channel bot CRUD, Feishu bot event, `/api/bots/*`, and public `/users` @@ -826,7 +969,7 @@ identities. Agents are reusable runtime capabilities. Mentions belong to the channel identity layer, not the runtime layer. It also removes the ID-equality assumption. A Feishu participant can be named -`u-test` and bind to underlying agent `u-qa` explicitly. The relationship is a +`test` and bind to underlying agent `u-qa` explicitly. The relationship is a stored foreign key rather than a naming convention. The result is a single target API rather than two competing surfaces. UI code no diff --git a/docs/participant-architecture.zh.md b/docs/participant-architecture.zh.md index e999cd39..4d323bb6 100644 --- a/docs/participant-architecture.zh.md +++ b/docs/participant-architecture.zh.md @@ -4,21 +4,35 @@ - 核心变化是把 `Participant`、`Agent`、`ChannelUser` 和产品文案里的 `Bot` 拆开:Participant 是 room/message/member/mention 使用的协作身份;Agent 是 runtime 执行实体;ChannelUser 是 channel 内部 identity/profile;Bot 不再是后端 API 和存储模型。 - UI 创建一个能出现在 CSGClaw 自带 IM 里的 Agent 时,应调用 `POST /api/v1/channels/csgclaw/participants`,使用 `type=agent` 和 `agent_binding.mode=create`,由服务端一次性创建 Agent、ChannelUser 和 Participant。 -- 跨 channel 复用不再依赖 ID 相等。同一个 Agent 可以有多个 participant,例如 `csgclaw:u-qa -> agent:u-qa` 和 `feishu:u-test -> agent:u-qa`。 +- 新建 agent-backed participant 的 Agent ID 生成关系保持旧约定:`agent_id = u-{participant_id}`。Participant ID 应来自显式 `id` 或稳定 key,不能从后续可修改的 `name` 派生。 +- 跨 channel 复用不再依赖 ID 相等。同一个 Agent 可以有多个 participant,例如 `csgclaw:qa -> agent:u-qa` 和 `feishu:test -> agent:u-qa`。 - Mention 走 participant 身份层。Feishu 真人 mention 使用 `channel_user_ref` 加 `channel_app_ref` / `channel_user_kind` 显式解析,不要求真人拥有 bot app/config。 - Notification 是 `type=notification` 的 participant,表示 webhook、系统事件或 pull relay 这类通知来源;默认不绑定 Agent,也不暴露 LLM bridge。 - Message 新 API 使用 `mentions` / `mention_ids` 数组;room membership 从 `user_ids` 迁移到 `participant_ids` 或结构化 `ParticipantRef`。 - 删除 participant 默认只删除 channel 身份绑定,不删除底层 Agent;只有显式 `delete_agent=if_unreferenced` 这类语义才允许清理未被引用的 Agent。 -- 这次是前后端、runtime bridge 和内置模板同步更新的 breaking API 调整:旧 bot 路由、公开 `/users` 路由不保留兼容别名。 +- 这次是前后端、runtime bridge、CLI 和内置模板同步更新的 breaking API 调整:API 兼容性不是迁移重点,旧 bot 路由、公开 `/users` 路由不保留兼容别名。 +- 旧 `bots.json` 等磁盘数据仍应可迁移;只要发现 runtime image/template contract 过旧,就在 UI 上提醒 recreate;当前 recreate 只承诺保留用户安装的 skills。 - Matrix 对齐只覆盖本次涉及的 identity、membership、message、mention 和 thread 形状,不实现完整 Matrix homeserver 或 Client-Server API。 +## 迁移优先事项 + +这次前后端一起发布,API breaking 不作为迁移风险;迁移重点是本地磁盘状态和旧 runtime image。 + +- **`bots.json`**:旧 bot 记录仍要能读取,并迁移成 participant 记录。普通 bot 迁移为 `type=agent` participant,保留原 `agent_id` / `channel_user_ref`;notification bot 迁移为 `type=notification` participant。 +- **IM state**:`im/state.json` 和 `im/sessions/*.jsonl` 里的旧 `users`、room `members`、message `sender_id`、`mentions`、thread context 等身份引用,需要从旧 user/bot ID 映射到 participant ID。 +- **Feishu config**:`channels/feishu.toml` 里按旧 `bot_id` 保存的 app/config,要迁移到 participant/channel app 语义,避免飞书发送和 mention 解析丢配置。 +- **Team state**:`teams/*` 下的 `lead_bot_id`、`member_bot_ids`、`bot_id`、`actor_id`、`created_by`、`assigned_to`、`requested_by`、`approver_id` 等字段,要迁移为 participant ID。 +- **Agents state**:`agents/state.json` 的 Agent ID 不需要改写;新 participant 记录继续通过 `agent_id` 指向原 Agent。 +- **旧镜像提醒**:只要发现 runtime image/template contract 过旧,就在 UI 上提醒用户 recreate。 +- **recreate 保留 skills**:当前 recreate 只承诺保留用户安装的 skills;不在这次计划里扩展为保留 workspace/project 状态。 + ## 背景 CSGClaw 目前的 `bot`、`agent` 和 channel `user` 概念在最早的本地多 Agent 协作里可以工作,但面对多渠道、多真人、多机器人协作时边界不够清楚。 最直接的问题是 mention 身份。Agent 需要在 CSGClaw 自带 IM、飞书以及未来其他 IM 里 @ 真人。当前 bot 模型默认消息发送者和 mention 对象都是 bot 类身份。在飞书里,这会导致 mention 通过已配置 bot app 身份解析,Agent 无法自然地 @ 一个真人 open_id。 -还有一个命名和归属问题。UI 里很多操作叫 Agent 管理,但部分客户端实际调用 channel bot API。与此同时,底层 runtime agent 本来就可以跨 channel 复用。例如,Feishu bot 可以建模为“飞书 channel 身份 + 一个原本从 CSGClaw channel 创建的底层 Agent”。现在这种复用主要依赖 `u-manager` 这类相同 ID 约定。如果 CSGClaw 里的 agent 是 `u-qa`,但飞书侧 bot 叫 `u-test`,这个关系就无法清晰表达。 +还有一个命名和归属问题。UI 里很多操作叫 Agent 管理,但部分客户端实际调用 channel bot API。与此同时,底层 runtime agent 本来就可以跨 channel 复用。例如,Feishu bot 可以建模为“飞书 channel 身份 + 一个原本从 CSGClaw channel 创建的底层 Agent”。现在这种复用主要依赖 `u-manager` 这类相同 ID 约定。如果 CSGClaw 里的 agent 是 `u-qa`,但飞书侧 participant 叫 `test`,这个关系就无法清晰表达。 目标设计是拆开这些概念: @@ -65,6 +79,8 @@ Agent 规则: - Agent ID 全局唯一。 +- 新建 agent-backed participant 时,如果请求未显式指定 Agent ID,服务端按 `u-{participant_id}` 生成 Agent ID。这个关系保持旧 worker/bot ID 的习惯,例如 participant `qa` 对应 agent `u-qa`。 +- 如果调用方显式指定 Agent ID,仍必须满足全局唯一,并且不要求和 participant ID 相同;跨 channel 复用时通常显式传已有 `agent_id`。 - Agent 生命周期操作继续放在 `/api/v1/agents`。 - Agent profile、model、runtime、日志、start、stop、restart、recreate 仍由 agent service 管理。 @@ -104,11 +120,34 @@ Participant - 一个 Agent 可以跨 channel 拥有多个 participant: ```text -csgclaw:u-qa -> agent:u-qa -feishu:u-test -> agent:u-qa +csgclaw:qa -> agent:u-qa +feishu:test -> agent:u-qa matrix:qa-bot -> agent:u-qa ``` +### Participant ID 生成规则 + +Participant ID 是用户和 CLI 经常看到的 channel 身份 ID,应优先可读且稳定,而不是默认使用裸 UUID。UUID 可以作为内部随机源或兜底值,但直接暴露给用户会降低可读性,也不利于 room membership、mention 和 CLI 操作。 + +Participant ID 不能从 `name` 生成。`name` 是显示名,后续可能支持修改;ID 一旦进入 room member、message、mention、agent binding 和 CLI,就必须稳定。业界更常见的做法是“稳定 slug + 短随机冲突后缀”,例如 Kubernetes object name 或很多 SaaS 的 workspace slug。只有完全不面向用户操作的内部对象,才更适合直接暴露 `usr_...`、`agt_...` 这类带类型前缀的 opaque ID。 + +推荐生成算法: + +1. 如果请求显式传入 `id`,先 normalize 并校验唯一性。 +2. 如果没有显式 `id`,只能从稳定来源生成 slug,例如创建请求里的独立 `slug` / `handle` 字段、内置模板 key、角色 key、外部 channel 的不可变 handle,或迁移时的旧 bot/user ID。不要使用可修改的显示名 `name`。 +3. Slug 规则:小写;去掉首尾空白;把连续非 `[a-z0-9]` 字符替换成 `-`;折叠连续 `-`;去掉首尾 `-`;建议长度限制为 3 到 48 个字符。 +4. 如果 slug 为空,按类型生成可读前缀加短随机后缀,例如 `agent-8f3k2m`、`human-8f3k2m`、`notification-8f3k2m`。 +5. 如果 slug 已存在,在 slug 后追加短随机后缀,例如 `qa-8f3k2m`。短随机后缀可以来自 UUID/ULID/nanoid 的 base32/base36 截断值。 +6. 服务端返回最终 participant ID;同一个 `request_id` 或 `client_transaction_id` 重试时必须返回同一个 ID。 + +Agent-backed participant 的默认 Agent ID 生成规则保持: + +```text +agent_id = "u-" + participant_id +``` + +因此新建 CSGClaw IM Agent 时,participant `qa` 默认生成 agent `u-qa`,同时 CSGClaw channel user ref 也可以继续使用 `u-qa`,保持旧 runtime、workspace 和 mention 习惯。 + ### Channel User / Channel Identity 引入 Participant 之后,现有对外 `User` 模型不应该继续作为顶层产品 API 保留。对外创建、查询、更新和删除“人”的入口应统一替换成 participant API。 @@ -301,7 +340,7 @@ POST /api/v1/channels/{channel}/participants ```json { - "id": "u-qa", + "id": "qa", "type": "agent", "name": "qa", "channel_user": { @@ -347,7 +386,7 @@ POST /api/v1/channels/{channel}/participants ```json { - "id": "u-test", + "id": "test", "type": "agent", "name": "QA", "channel_user": { @@ -366,7 +405,7 @@ POST /api/v1/channels/{channel}/participants ```json { - "id": "human-alice", + "id": "alice", "type": "human", "name": "Alice", "channel_user": { @@ -422,13 +461,13 @@ GET /api/v1/agents/{id}?include_participants=true "status": "running", "participants": [ { - "id": "u-qa", + "id": "qa", "channel": "csgclaw", "type": "agent", "channel_user_ref": "u-qa" }, { - "id": "u-test", + "id": "test", "channel": "feishu", "type": "agent", "channel_user_ref": "ou_xxx" @@ -544,7 +583,7 @@ Channel 或 Room 页面 "room_id": "oc_xxx", "mentions": [ { - "id": "human-alice" + "id": "alice" } ], "content": "please take a look" @@ -554,10 +593,10 @@ Channel 或 Room 页面 Channel adapter 解析链路: ```text -path id -> Participant(channel=feishu, id=u-test) +path id -> Participant(channel=feishu, id=test) -> sender 所需的 channel_user_ref/channel_app_ref -mentions[].id -> Participant(channel=feishu, id=human-alice) +mentions[].id -> Participant(channel=feishu, id=alice) -> channel_user_ref=open_id ``` @@ -599,10 +638,40 @@ UI 不应该把 participant、agent、channel user 这些内部模型词作为 UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 participant 和 agent 的分层清晰。 +## CLI 变化 + +CLI 的规范资源名应跟随后端模型,使用 `participant` 作为协作身份入口,并提供更短的 `pt` 子命令别名。`participant` 用于文档、脚本和长期稳定引用;`pt` 用于交互式日常操作。`bot` 可以作为面向使用者的轻量别名保留给 `type=agent` 场景,但输出 JSON、API payload 和错误信息应使用 participant 语义,避免继续暴露 Bot 存储模型。 + +推荐命令形状: + +```text +csgclaw participant list --channel csgclaw --type agent +csgclaw participant create --channel csgclaw --type agent --id qa --name QA --bind create +csgclaw participant create --channel feishu --type agent --id test --bind reuse --agent-id u-qa --channel-user-ref ou_xxx --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant create --channel feishu --type human --id alice --name Alice --channel-user-ref ou_alice --channel-user-kind open_id --channel-app-ref cli_xxx +csgclaw participant delete --channel feishu test +csgclaw participant delete --channel feishu test --delete-agent if-unreferenced +csgclaw pt list --channel csgclaw --type agent +csgclaw pt create --channel csgclaw --type agent --id qa --name QA --bind create +``` + +CLI 字段改名应和 API 一致: + +- `pt` 是 `participant` 的等价短别名,所有 `participant` 子命令、flag、输出和错误语义都必须一致。 +- `bot list/create/delete` 不再是规范命令;如果保留,应只是 `participant --type agent` / `pt --type agent` 的产品别名。 +- `agent create` 只负责创建 runtime-only Agent,不应作为“创建可聊天 CSGClaw IM Agent”的主入口。 +- `user list/create/delete` 迁移为 `participant list/create/delete --type human`。 +- room member 命令中的 `--user-id`、`--user-ids`、`--member-ids` 应改为 `--participant-id`、`--participant-ids` 或结构化 participant ref。 +- message 命令中的 `--sender-id` 应改为 path 或显式 `--participant-id`;`--mention-id` 应支持重复传入或改为 `--mention-participant-id`,并发送为 `mentions` / `mention_ids` 数组。 +- Feishu 配置命令中的 `--bot-id` 应改为 `--participant-id` 或 `--channel-app-ref`,取决于命令是在配置 participant 绑定,还是在管理 Feishu app/config。 +- team/task 命令里的 `--lead-bot-id`、`--member-bot-ids`、`--bot-id`、`--actor-id` 应改为 `--lead-participant-id`、`--member-participant-ids`、`--participant-id`、`--actor-participant-id`;只有明确操作 runtime 时才使用 `--agent-id`。 +- `csgclaw-cli` 这类 runtime 内置命令也要同步更新;内置技能和模板不能继续依赖旧 `bot_id`、`sender_id`、`mention_id` 和 `user_ids` 语义。 + ## 一步到位实施范围 - 新增 participant request/response types。 - 新增 participant storage,规范 key 为 `(channel, id)`。 +- 新增 Participant ID 生成器:从显式 `id` 或稳定 key 生成可读 slug,冲突时追加短随机后缀;不要从可修改的 `name` 派生 ID;新建 agent-backed participant 的默认 Agent ID 保持 `u-{participant_id}`。 - 用 participant API 替换公开 `User` API。只有 CSGClaw 和外部 channel adapter 需要时,才保留内部 channel identity/profile store。 - 新增 participant service,支持 list、get、create、patch、delete 和 agent binding。 - 注册 `/api/v1/channels/{channel}/participants`。 @@ -614,7 +683,7 @@ UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 pa - 在 `/api/v1/agents/{agent_id}/llm/*` 下注册 agent LLM 路由。 - 实现 `create`、`reuse`、`none` 三种创建模式。 - 支持 `include_agent` 和 `include_channel_user` 响应展开。 -- 增加测试:创建 Feishu participant `u-test` 并绑定到 agent `u-qa`。 +- 增加测试:创建 Feishu participant `test` 并绑定到 agent `u-qa`。 - sender 和 mention ID 统一通过 participant service 解析。 - 更新 CSGClaw IM mention 渲染,支持 human 和 agent participant。 - 更新 Feishu 发送链路,让 mention 解析到 participant 的 `channel_user_ref`,不再要求每个 mention 对象都有配置好的 bot app。 @@ -624,8 +693,8 @@ UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 pa - 将 room membership 请求字段从 `user_ids` 改为 `participant_ids` 或结构化 participant ref。 - 为 `GET /api/v1/agents` 增加 `participants` 展开。 - 包含飞书和未来 channel store 中的 participant。 -- 增加测试:agent `u-qa` 同时展示 `csgclaw:u-qa` 和 `feishu:u-test` 两个绑定。 -- 增加测试:删除 `feishu:u-test` participant 不删除仍被 `csgclaw:u-qa` 使用的 agent `u-qa`。 +- 增加测试:agent `u-qa` 同时展示 `csgclaw:qa` 和 `feishu:test` 两个绑定。 +- 增加测试:删除 `feishu:test` participant 不删除仍被 `csgclaw:qa` 使用的 agent `u-qa`。 - 用基于意图的 channel action 替代当前 create-agent/create-bot 混淆。 - CSGClaw UI 创建可聊天 Agent 时,使用 `POST /api/v1/channels/csgclaw/participants`,不要直接创建 agent 后再单独创建 user。 @@ -633,6 +702,8 @@ UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 pa - 增加“添加真人”流程。 - 用 participant-scoped notification endpoint 替换当前 notification bot webhook/pull 路由。 - Agent 页面聚焦 runtime 配置和生命周期。 +- 更新 CLI 和 `csgclaw-cli`:规范命令使用 participant,并注册 `pt` 短别名;旧 bot/user/member/message 参数同步改为 participant 语义。 +- 迁移旧 `bots.json`、IM state、Feishu config 和 Team state 里的身份引用;旧 runtime image/template contract 过期时在 UI 提醒 recreate,当前 recreate 只保留用户 skills。 - 本次不实现聊天记录同步、sync storage 或 sync API;只保证身份模型兼容未来同步。 - 在同一个变更中删除 channel bot CRUD、Feishu bot event、`/api/bots/*` 和公开 `/users` 路由。 - 删除旧 handler 前,先把 runtime bridge 调用方替换成 participant/agent scoped 路由。 @@ -641,6 +712,6 @@ UI 可以继续使用 Bot、Person 这类产品友好的名称。后端保持 pa 这个模型符合真实领域边界。真人和 bot 是 channel 身份,Agent 是可复用的 runtime 能力。Mention 属于 channel 身份层,不属于 runtime 层。 -它也去掉了 ID 相等假设。Feishu participant 可以叫 `u-test`,同时显式绑定到底层 agent `u-qa`。这个关系是持久化外键,不再是命名约定。 +它也去掉了 ID 相等假设。Feishu participant 可以叫 `test`,同时显式绑定到底层 agent `u-qa`。这个关系是持久化外键,不再是命名约定。 最终结果是一个目标 API,而不是两个并存的 API 面。UI 创建 Agent 时不再调用 bot API;channel 流程显式创建 participant。UI 仍然可以用用户意图组织流程,而不是暴露内部模型术语。 From 46580e25d583f3eb27d5f3d649dc311b7d6ca4bf Mon Sep 17 00:00:00 2001 From: Yun Long Date: Fri, 5 Jun 2026 15:34:27 +0800 Subject: [PATCH 3/6] Refactor(architecture): Use Participants API/Models --- Makefile | 12 +- cli/app.go | 35 +- cli/app_test.go | 387 +---- cli/bot/bot.go | 208 --- cli/bot/bot_test.go | 31 - cli/bot/config.go | 196 --- cli/command/command.go | 129 +- cli/command/command_test.go | 100 -- cli/completion/completion.go | 49 +- cli/completion/completion_test.go | 28 +- cli/csgclawcli/app.go | 38 +- cli/csgclawcli/app_test.go | 214 +-- cli/http_client.go | 4 - cli/member/member.go | 6 +- cli/message/message.go | 6 +- cli/participant/participant.go | 223 +++ cli/room/room.go | 4 +- cli/serve/serve.go | 74 +- cli/serve/serve_test.go | 73 +- docs/agent_teams.md | 4 +- docs/api.md | 193 +-- docs/api.zh.md | 186 +-- docs/architecture.md | 127 +- docs/channel/csgclaw.md | 14 +- docs/channel/csgclaw.zh.md | 14 +- docs/cli.md | 86 +- docs/cli.zh.md | 86 +- docs/im-threads.md | 22 +- docs/im-threads.zh.md | 20 +- docs/participant-architecture.md | 5 +- docs/participant-architecture.zh.md | 3 +- docs/sandbox/csghub.md | 11 +- internal/agent/manager_config.go | 58 +- internal/agent/manager_config_test.go | 66 +- internal/agent/runtime_state.go | 4 +- internal/agent/runtime_state_test.go | 6 +- internal/agent/service.go | 57 +- internal/agent/service_profiles.go | 9 +- internal/agent/service_test.go | 215 ++- internal/api/bot_compat.go | 314 ++-- internal/api/conversation_command.go | 36 +- internal/api/feishu.go | 22 +- internal/api/handler.go | 328 ++--- internal/api/handler_test.go | 1295 +++++------------ internal/api/llm.go | 45 + internal/api/notification_bots.go | 36 +- .../api/notification_bots_handler_test.go | 40 - internal/api/notification_bots_test.go | 122 -- internal/api/participant.go | 230 +++ internal/api/participant_test.go | 694 +++++++++ internal/api/rest_handlers.go | 36 +- internal/api/router.go | 44 +- internal/apiclient/client.go | 62 +- internal/apiclient/notification_bots.go | 37 - internal/apitypes/participant.go | 26 + .../app/channelwiring/notification_bot.go | 87 +- .../channelwiring/notification_bot_test.go | 47 + internal/app/runtimewiring/openclaw.go | 4 +- internal/app/runtimewiring/picoclaw.go | 17 +- internal/app/runtimewiring/picoclaw_test.go | 88 ++ internal/app/runtimewiring/sandbox.go | 2 +- internal/bot/service.go | 6 +- internal/bot/service_test.go | 32 +- internal/channel/codexbridge/bridge_test.go | 43 +- internal/channel/codexbridge/sse_client.go | 13 +- internal/channel/csgclaw/service_test.go | 34 +- internal/im/deliver_message_bus_test.go | 6 +- internal/im/service.go | 122 +- internal/im/service_test.go | 98 +- internal/im/service_thread_test.go | 10 +- internal/onboard/detect.go | 30 +- internal/onboard/detect_test.go | 34 +- internal/onboard/onboard.go | 25 +- internal/participant/model.go | 68 + internal/participant/service.go | 569 ++++++++ internal/participant/service_test.go | 416 ++++++ internal/participant/store.go | 419 ++++++ internal/participant/store_test.go | 112 ++ internal/runtime/openclawsandbox/config.go | 28 +- .../runtime/openclawsandbox/config_test.go | 41 +- internal/runtime/openclawsandbox/runtime.go | 6 +- internal/runtime/picoclawsandbox/config.go | 31 +- .../runtime/picoclawsandbox/config_test.go | 39 + .../defaults/picoclaw-config.json | 8 +- .../runtime/picoclawsandbox/provision_test.go | 2 +- internal/runtime/picoclawsandbox/runtime.go | 6 +- .../picoclawsandbox/runtime_channels_test.go | 16 +- internal/runtime/provision.go | 1 + internal/runtime/sandboxgateway/runtime.go | 29 +- .../runtime/sandboxgateway/runtime_test.go | 2 +- internal/server/accesslog_test.go | 6 +- internal/server/http.go | 5 +- internal/server/ui.go | 18 + internal/server/ui_test.go | 45 + .../openclaw-manager/workspace/AGENTS.md | 6 +- .../workspace/skills/agent-creator/SKILL.md | 20 +- .../skills/agent-creator/agents/openai.yaml | 2 +- .../workspace/skills/basics/SKILL.md | 44 +- .../workspace/skills/feishu/SKILL.md | 79 +- .../feishu/scripts/feishu_setup/commands.py | 2 +- .../feishu/scripts/feishu_setup/csgclaw.py | 47 +- .../scripts/tests/test_manager_action_card.py | 2 +- .../skills/manager-worker-dispatch/SKILL.md | 14 +- .../references/api-contract.md | 4 +- .../scripts/manager_worker_api.py | 6 +- .../embed/picoclaw-manager/workspace/AGENT.md | 6 +- .../workspace/skills/agent-creator/SKILL.md | 20 +- .../skills/agent-creator/agents/openai.yaml | 2 +- .../workspace/skills/basics/SKILL.md | 44 +- .../workspace/skills/feishu/SKILL.md | 87 +- .../feishu/scripts/feishu_setup/commands.py | 2 +- .../feishu/scripts/feishu_setup/csgclaw.py | 47 +- .../skills/manager-worker-dispatch/SKILL.md | 14 +- .../references/api-contract.md | 4 +- .../scripts/manager_worker_api.py | 6 +- web/app/src/api/agents.ts | 83 +- web/app/src/api/im.ts | 2 +- .../src/hooks/workspace/useAgentController.ts | 27 +- web/app/src/models/agents.ts | 36 +- web/app/src/models/composer.ts | 58 +- .../AgentDetailPane/AgentDetailPane.tsx | 7 - .../components/AgentList/AgentList.tsx | 11 - web/app/src/shared/constants/agents.ts | 1 + .../tests/components/AgentActions.test.tsx | 29 +- .../components/ConversationPane.test.tsx | 3 +- .../tests/components/WorkspaceTabBar.test.tsx | 4 +- web/app/tests/legacy-contract.test.ts | 11 +- web/app/tests/models/agents.test.ts | 39 +- 128 files changed, 6097 insertions(+), 3607 deletions(-) delete mode 100644 cli/bot/bot.go delete mode 100644 cli/bot/bot_test.go delete mode 100644 cli/bot/config.go delete mode 100644 cli/command/command_test.go create mode 100644 cli/participant/participant.go delete mode 100644 internal/api/notification_bots_handler_test.go delete mode 100644 internal/api/notification_bots_test.go create mode 100644 internal/api/participant.go create mode 100644 internal/api/participant_test.go delete mode 100644 internal/apiclient/notification_bots.go create mode 100644 internal/apitypes/participant.go create mode 100644 internal/app/channelwiring/notification_bot_test.go create mode 100644 internal/app/runtimewiring/picoclaw_test.go create mode 100644 internal/participant/model.go create mode 100644 internal/participant/service.go create mode 100644 internal/participant/service_test.go create mode 100644 internal/participant/store.go create mode 100644 internal/participant/store_test.go create mode 100644 internal/runtime/picoclawsandbox/config_test.go create mode 100644 internal/server/ui_test.go diff --git a/Makefile b/Makefile index 1c62ebbb..a3303191 100644 --- a/Makefile +++ b/Makefile @@ -176,15 +176,19 @@ build-docker-embed-runtime-embed: build-docker-embed-images patch-docker-embed-i build-picoclaw-runtime-embed: build-docker-embed-runtime-embed build-picoclaw-manager-image: stage-docker-embed-cli - chmod +x scripts/prepare-docker-embed-dist.sh scripts/build-docker-embed-images.sh - scripts/prepare-docker-embed-dist.sh picoclaw-manager + chmod +x scripts/prepare-docker-embed-dist.sh scripts/patch-docker-embed-image-refs.sh scripts/build-docker-embed-images.sh + scripts/prepare-docker-embed-dist.sh + ACR_REGISTRY="$(ACR_REGISTRY)" VERSION="$(DOCKER_EMBED_IMAGE_TAG)" \ + scripts/patch-docker-embed-image-refs.sh ACR_REGISTRY="$(ACR_REGISTRY)" PICOCLAW_BASE_IMAGE="$(PICOCLAW_BASE_IMAGE)" \ DOCKER_EMBED_IMAGE_TAG="$(DOCKER_EMBED_IMAGE_TAG)" \ scripts/build-docker-embed-images.sh picoclaw-manager build-picoclaw-worker-image: stage-docker-embed-cli - chmod +x scripts/prepare-docker-embed-dist.sh scripts/build-docker-embed-images.sh - scripts/prepare-docker-embed-dist.sh picoclaw-worker + chmod +x scripts/prepare-docker-embed-dist.sh scripts/patch-docker-embed-image-refs.sh scripts/build-docker-embed-images.sh + scripts/prepare-docker-embed-dist.sh + ACR_REGISTRY="$(ACR_REGISTRY)" VERSION="$(DOCKER_EMBED_IMAGE_TAG)" \ + scripts/patch-docker-embed-image-refs.sh ACR_REGISTRY="$(ACR_REGISTRY)" PICOCLAW_BASE_IMAGE="$(PICOCLAW_BASE_IMAGE)" \ DOCKER_EMBED_IMAGE_TAG="$(DOCKER_EMBED_IMAGE_TAG)" \ scripts/build-docker-embed-images.sh picoclaw-worker diff --git a/cli/app.go b/cli/app.go index bfe57b1d..27499dba 100644 --- a/cli/app.go +++ b/cli/app.go @@ -10,13 +10,13 @@ import ( "strings" agentcmd "csgclaw/cli/agent" - "csgclaw/cli/bot" "csgclaw/cli/command" completioncmd "csgclaw/cli/completion" hubcmd "csgclaw/cli/hub" "csgclaw/cli/member" "csgclaw/cli/message" modelcmd "csgclaw/cli/model" + participantcmd "csgclaw/cli/participant" "csgclaw/cli/room" servecmd "csgclaw/cli/serve" skillcmd "csgclaw/cli/skill" @@ -79,8 +79,9 @@ func (a *App) registerDefaultCommands() { hubcmd.NewCmd(), skillcmd.NewCmd(), modelcmd.NewCmd(), + participantcmd.NewCmd(), + participantcmd.NewAliasCmd("pt"), usercmd.NewCmd(), - bot.NewCmd(), room.NewCmd(), member.NewCmd(), message.NewCmd(), @@ -205,11 +206,10 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " csgclaw [global-flags] [args]") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Available Commands:") - for _, cmd := range a.order { - if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { - continue - } - fmt.Fprintf(a.stderr, " %-8s %s\n", cmd.Name(), cmd.Summary()) + commands := a.visibleCommands() + width := commandNameWidth(commands) + for _, cmd := range commands { + fmt.Fprintf(a.stderr, " %-*s %s\n", width, cmd.Name(), cmd.Summary()) } fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Examples:") @@ -230,6 +230,27 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " --version, -V Print version and exit") } +func (a *App) visibleCommands() []command.Command { + commands := make([]command.Command, 0, len(a.order)) + for _, cmd := range a.order { + if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { + continue + } + commands = append(commands, cmd) + } + return commands +} + +func commandNameWidth(commands []command.Command) int { + width := 8 + for _, cmd := range commands { + if n := len(cmd.Name()); n > width { + width = n + } + } + return width + 1 +} + func (a *App) printVersion(output string) error { version := appversion.Current() if output == "json" { diff --git a/cli/app_test.go b/cli/app_test.go index 5b6ca685..1e26fc41 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -17,8 +17,8 @@ import ( "testing" "csgclaw/internal/apitypes" - "csgclaw/internal/bot" "csgclaw/internal/channel/feishu" + "csgclaw/internal/participant" appversion "csgclaw/internal/version" ) @@ -199,118 +199,7 @@ func TestExecuteAgentStopUsesHTTPClient(t *testing.T) { assertTableHasRow(t, stdout.String(), "u-alice", "alice", "worker", "stopped", "codex", "codex-main", "ghcr.io/opencsg/csgclaw-agent:2026.4.28") } -func TestExecuteBotListUsesDefaultChannel(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want csgclaw bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-alice","name":"alice","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "bot-alice", "alice", "-", "worker", "csgclaw", "u-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z") -} - -func TestExecuteBotListFeishuUsesChannelQuery(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, want := range []string{`"agent_id": "u-manager"`, `"user_id": "fsu-manager"`, `"created_at": "2026-04-12T09:00:00Z"`} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want full csgclaw bot list field %s", stdout.String(), want) - } - } -} - -func TestExecuteBotListUsesChannelBotsRoute(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want GET", req.Method) - } - if strings.Contains(req.URL.String(), "type=notification") { - t.Fatalf("url = %q, bot list must not use type=notification query", req.URL.String()) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want channel bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[]`), nil - }), - } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotListUsesTypeQuery(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots?type=notification" { - t.Fatalf("url = %q, want type=notification on bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[]`), nil - }), - } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--type", "notification"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotListUsesRoleQuery(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots?role=worker" { - t.Fatalf("url = %q, want role-filtered bot list route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `[{"id":"bot-alice","name":"alice","description":"abcdefghijklmnopqrstuvwxyz1234567890ABCDE","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}]`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--role", "worker"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "bot-alice", "alice", "abcdefghijklmnopqrstuvwxyz1234567890ABCD...", "worker", "csgclaw", "u-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z") -} - -func TestExecuteBotCreateUsesDefaultChannel(t *testing.T) { +func TestExecuteParticipantCreateSendsTemplateDescriptionAndEnv(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -319,205 +208,54 @@ func TestExecuteBotCreateUsesDefaultChannel(t *testing.T) { if req.Method != http.MethodPost { t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://example.test/api/v1/channels/csgclaw/bots") + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant route", req.URL.String()) } - var payload bot.CreateRequest + var payload participant.CreateRequest if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { t.Fatalf("decode request: %v", err) } - if payload.Name != "alice" || payload.Role != "worker" || payload.Channel != "csgclaw" { - t.Fatalf("payload = %+v, want alice worker csgclaw", payload) + if payload.ID != "u-gitlab" || payload.Name != "gitlab" || payload.Type != "agent" || payload.Channel != "feishu" { + t.Fatalf("payload = %+v, want u-gitlab gitlab agent feishu", payload) } - if payload.Description != "test lead" { - t.Fatalf("payload = %+v, want description", payload) + if payload.Metadata["description"] != "GitLab worker" { + t.Fatalf("payload.Metadata = %#v, want description", payload.Metadata) } - if payload.AgentProfile == nil || payload.AgentProfile.ModelID != "gpt-test" { - t.Fatalf("payload.AgentProfile = %+v, want model_id", payload.AgentProfile) + if payload.AgentBinding.Mode != "create" || payload.AgentBinding.AgentID != "u-gitlab" || payload.AgentBinding.Agent == nil { + t.Fatalf("payload.AgentBinding = %+v, want create u-gitlab", payload.AgentBinding) } - if payload.RuntimeKind != "codex" { - t.Fatalf("payload.RuntimeKind = %q, want codex", payload.RuntimeKind) + spec := payload.AgentBinding.Agent + if spec.Description != "GitLab worker" || spec.FromTemplate != "builtin.gitlab-worker" || spec.Role != "worker" { + t.Fatalf("payload.AgentBinding.Agent = %+v, want description/template/role", spec) } - return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","description":"test-lead","role":"worker","channel":"csgclaw","runtime_kind":"codex","agent_id":"u-alice","user_id":"u-alice","available":true,"created_at":"2026-04-12T09:00:00Z"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--name", "alice", "--description", "test lead", "--role", "worker", "--model-id", "gpt-test", "--runtime", "codex"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - assertTableHasRow(t, stdout.String(), "u-alice", "alice", "test-lead", "worker", "csgclaw") -} - -func TestExecuteBotCreateSendsFromTemplateAndEnv(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) + if spec.AgentProfile.ModelID != "gpt-test" || spec.AgentProfile.Env["GITLAB_TOKEN"] != "secret" { + t.Fatalf("payload.AgentBinding.Agent.AgentProfile = %+v, want model and env", spec.AgentProfile) } - var payload bot.CreateRequest - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - if payload.FromTemplate != "builtin.gitlab-worker" { - t.Fatalf("payload.FromTemplate = %q, want builtin.gitlab-worker", payload.FromTemplate) - } - if payload.AgentProfile == nil || payload.AgentProfile.Env["GITLAB_TOKEN"] != "secret" { - t.Fatalf("payload.AgentProfile.Env = %#v, want GITLAB_TOKEN", payload.AgentProfile) - } - return jsonResponse(http.StatusCreated, `{"id":"u-gitlab","name":"gitlab","description":"gitlab-worker","role":"worker","channel":"csgclaw","runtime_kind":"picoclaw_sandbox","agent_id":"u-gitlab","user_id":"u-gitlab","available":true,"created_at":"2026-04-12T09:00:00Z"}`), nil + return jsonResponse(http.StatusCreated, `{"id":"u-gitlab","name":"gitlab","type":"agent","channel":"feishu","agent_id":"u-gitlab","channel_user_ref":"u-gitlab","lifecycle_status":"active","metadata":{"description":"GitLab worker"},"created_at":"2026-04-12T09:00:00Z"}`), nil }), } err := app.Execute(context.Background(), []string{ "--endpoint", "http://example.test", - "bot", "create", + "--output", "json", + "participant", "create", + "--id", "u-gitlab", "--name", "gitlab", - "--description", "gitlab worker", + "--description", "GitLab worker", + "--type", "agent", + "--channel", "feishu", + "--bind", "create", + "--agent-id", "u-gitlab", "--role", "worker", "--from-template", "builtin.gitlab-worker", + "--model-id", "gpt-test", "--env", "GITLAB_TOKEN=secret", }) if err != nil { t.Fatalf("Execute() error = %v", err) } - assertTableHasRow(t, stdout.String(), "u-gitlab", "gitlab", "gitlab-worker", "worker", "csgclaw") -} - -func TestExecuteBotCreateFeishuSendsChannelPayload(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) - } - var payload bot.CreateRequest - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - if payload.ID != "u-alice" || payload.Name != "alice" || payload.Role != "worker" || payload.Channel != "feishu" { - t.Fatalf("payload = %+v, want u-alice alice worker feishu", payload) - } - return jsonResponse(http.StatusCreated, `{"id":"u-alice","name":"alice","role":"worker","channel":"feishu","agent_id":"u-alice","user_id":"u-alice","created_at":"2026-04-12T09:00:00Z"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "create", "--id", "u-alice", "--name", "alice", "--role", "worker", "--channel", "feishu"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), `"id": "u-alice"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON feishu bot payload", stdout.String()) - } -} - -func TestExecuteBotDeleteUsesDefaultChannel(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - if req.URL.String() != "http://example.test/api/v1/channels/csgclaw/bots/u-alice" { - t.Fatalf("url = %q, want csgclaw bot delete route", req.URL.String()) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotDeleteFeishuUsesChannelQuery(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots/u-alice" { - t.Fatalf("url = %q, want feishu bot delete route", req.URL.String()) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "--channel", "feishu", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } -} - -func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodDelete { - t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) - } - return jsonResponse(http.StatusNoContent, ``), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "delete", "--channel", "feishu", "u-alice"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - for _, want := range []string{`"command": "bot"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { - if !strings.Contains(stdout.String(), want) { - t.Fatalf("stdout = %q, want %s", stdout.String(), want) - } - } -} - -func TestExecuteBotConfigGetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config?bot_id=u-dev" { - t.Fatalf("url = %q, want feishu config get route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present"}`), nil - }), - } - - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-dev"}); err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "u-dev") || !strings.Contains(stdout.String(), "present") { - t.Fatalf("stdout = %s, want bot and masked secret", stdout.String()) - } -} - -func TestExecuteBotCreateRequiresNameAndRole(t *testing.T) { - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, nil }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--role", "worker"}) - if err == nil || !strings.Contains(err.Error(), "requires --name") { - t.Fatalf("Execute(missing name) error = %v, want --name error", err) - } - - err = app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "create", "--name", "alice"}) - if err == nil || !strings.Contains(err.Error(), "requires --role") { - t.Fatalf("Execute(missing role) error = %v, want --role error", err) + if !strings.Contains(stdout.String(), `"id": "u-gitlab"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { + t.Fatalf("stdout = %q, want created participant JSON", stdout.String()) } } @@ -1969,19 +1707,23 @@ func TestUsageIncludesTopLevelCommandIndex(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "agent Manage agents", - "model Manage model providers.", - "bot Manage bots", - "room Manage IM rooms", - "member Manage IM room members", - "team Manage agent teams.", - "user Manage IM users", - "completion Generate shell completion scripts.", + "agent Manage agents", + "model Manage model providers.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "room Manage IM rooms", + "member Manage IM room members", + "team Manage agent teams.", + "user Manage IM users", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("usage = %q, want substring %q", got, want) } } + if strings.Contains(got, "Manage bots") || strings.Contains(got, "\n bot ") { + t.Fatalf("usage = %q, should not include bot command", got) + } } func TestRootHelpIncludesAvailableCommands(t *testing.T) { @@ -2000,19 +1742,23 @@ func TestRootHelpIncludesAvailableCommands(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "agent Manage agents", - "model Manage model providers.", - "bot Manage bots", - "room Manage IM rooms", - "member Manage IM room members", - "team Manage agent teams.", - "user Manage IM users", - "completion Generate shell completion scripts.", + "agent Manage agents", + "model Manage model providers.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "room Manage IM rooms", + "member Manage IM room members", + "team Manage agent teams.", + "user Manage IM users", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("help = %q, want substring %q", got, want) } } + if strings.Contains(got, "Manage bots") || strings.Contains(got, "\n bot ") { + t.Fatalf("help = %q, should not include bot command", got) + } } func TestExecuteDoesNotRegisterTopLevelAuthAliases(t *testing.T) { @@ -2266,33 +2012,6 @@ func TestAgentHelpIncludesSubcommands(t *testing.T) { } } -func TestBotHelpIncludesSubcommands(t *testing.T) { - var stderr bytes.Buffer - app := &App{ - stdout: &bytes.Buffer{}, - stderr: &stderr, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, nil }), - } - - err := app.Execute(context.Background(), []string{"bot", "-h"}) - if err != flag.ErrHelp { - t.Fatalf("Execute() error = %v, want %v", err, flag.ErrHelp) - } - - got := stderr.String() - for _, want := range []string{ - "Manage bots.", - "csgclaw bot [flags]", - "list List bots", - "create Create a bot", - "delete Delete a bot", - } { - if !strings.Contains(got, want) { - t.Fatalf("help = %q, want substring %q", got, want) - } - } -} - func TestAgentSubcommandHelpIncludesUsageAndFlags(t *testing.T) { var stderr bytes.Buffer app := &App{ @@ -2395,7 +2114,7 @@ func TestExecuteStartIsRejected(t *testing.T) { if !strings.Contains(err.Error(), `unknown command "start"`) { t.Fatalf("Execute() error = %v, want unknown command start", err) } - if !strings.Contains(stderr.String(), " serve Start the local HTTP server") { + if !strings.Contains(stderr.String(), " serve Start the local HTTP server") { t.Fatalf("stderr = %q, want serve command in usage", stderr.String()) } } diff --git a/cli/bot/bot.go b/cli/bot/bot.go deleted file mode 100644 index 78021378..00000000 --- a/cli/bot/bot.go +++ /dev/null @@ -1,208 +0,0 @@ -package bot - -import ( - "context" - "flag" - "fmt" - "strings" - - "csgclaw/cli/command" - "csgclaw/internal/apitypes" - botdomain "csgclaw/internal/bot" -) - -type cmd struct{} - -func NewCmd() command.Command { - return cmd{} -} - -func (cmd) Name() string { - return "bot" -} - -func (cmd) Summary() string { - return "Manage bots." -} - -func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - if len(args) == 0 { - c.usage(run) - return flag.ErrHelp - } - if command.IsHelpArg(args[0]) { - c.usage(run) - return flag.ErrHelp - } - - switch args[0] { - case "list": - return c.runList(ctx, run, args[1:], globals) - case "create": - return c.runCreate(ctx, run, args[1:], globals) - case "delete": - return c.runDelete(ctx, run, args[1:], globals) - case "config": - return c.runConfig(ctx, run, args[1:], globals) - default: - c.usage(run) - return fmt.Errorf("unknown bot subcommand %q", args[0]) - } -} - -func (c cmd) usage(run *command.Context) { - subcommands := []string{ - "list List bots (--type normal|notification optional; csgclaw default includes notification)", - "create Create a bot", - "delete Delete a bot", - "config Manage bot channel config", - } - run.UsageCommandGroup(c, run.Program+" bot [flags]", subcommands) -} - -func (c cmd) runList(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot list", run.Program+" bot list [flags]", "List bots (csgclaw includes notification bots; feishu lists normal bots only).") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - role := fs.String("role", "", "bot role: manager or worker") - botType := fs.String("type", "", "bot type filter: normal or notification (default: all types allowed for channel)") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) != 0 { - return fmt.Errorf("bot list does not accept positional arguments") - } - - client := run.APIClient(globals) - typeFilter := strings.TrimSpace(*botType) - if typeFilter != "" { - typeFilter = botdomain.NormalizeBotType(typeFilter) - } - bots, err := client.ListBots(ctx, *channelName, *role, typeFilter) - if err != nil { - return err - } - return renderBotList(run, globals, bots) -} - -func renderBotList(run *command.Context, globals command.GlobalOptions, bots []apitypes.Bot) error { - if strings.TrimSpace(run.Program) == "csgclaw-cli" { - return command.RenderCompactBotList(globals.Output, run.Stdout, bots) - } - return command.RenderFullBotList(globals.Output, run.Stdout, bots) -} - -func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot create", run.Program+" bot create [flags]", "Create a bot.") - id := fs.String("id", "", "bot id") - name := fs.String("name", "", "bot name") - description := fs.String("description", "", "bot description") - role := fs.String("role", "", "bot role: manager or worker") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - modelID := fs.String("model-id", "", "agent model identifier") - runtimeKind := fs.String("runtime", "", "agent runtime kind for worker bots (for example: picoclaw_sandbox, openclaw_sandbox, codex)") - fromTemplate := fs.String("from-template", "", "hub template to use as creation defaults and workspace overlay") - var envValues envFlag - fs.Var(&envValues, "env", "agent image environment variable as KEY=VALUE (repeatable)") - botType := fs.String("type", botdomain.BotTypeNormal, "bot type: normal or notification") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) != 0 { - return fmt.Errorf("bot create does not accept positional arguments") - } - if *name == "" { - return fmt.Errorf("bot create requires --name") - } - if *role == "" { - return fmt.Errorf("bot create requires --role") - } - - envMap, err := parseEnvAssignments(envValues) - if err != nil { - return err - } - - req := apitypes.CreateBotRequest{ - ID: *id, - Name: *name, - Description: *description, - Type: botdomain.NormalizeBotType(*botType), - Role: *role, - Channel: *channelName, - RuntimeKind: *runtimeKind, - FromTemplate: *fromTemplate, - } - if strings.TrimSpace(*modelID) != "" || len(envMap) > 0 { - req.AgentProfile = &apitypes.CreateAgentProfile{ModelID: *modelID, Env: envMap} - } - client := run.APIClient(globals) - var created apitypes.Bot - if req.Type == botdomain.BotTypeNotification { - created, err = client.CreateNotificationBot(ctx, req) - } else { - created, err = client.CreateBot(ctx, req) - } - if err != nil { - return err - } - return command.RenderBots(globals.Output, run.Stdout, []apitypes.Bot{created}) -} - -func (c cmd) runDelete(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet("bot delete", run.Program+" bot delete [flags]", "Delete a bot.") - channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") - if err := fs.Parse(args); err != nil { - return err - } - - rest := fs.Args() - if len(rest) != 1 { - return fmt.Errorf("bot delete requires exactly one id") - } - - if err := run.APIClient(globals).DeleteBot(ctx, *channelName, rest[0]); err != nil { - return err - } - return command.RenderAction(globals.Output, run.Stdout, command.ActionResult{ - Command: "bot", - Action: "delete", - Status: "deleted", - ID: rest[0], - Channel: *channelName, - Message: fmt.Sprintf("deleted %s bot %s", *channelName, rest[0]), - }) -} - -type envFlag []string - -func (e *envFlag) String() string { - return strings.Join(*e, ",") -} - -func (e *envFlag) Set(value string) error { - *e = append(*e, value) - return nil -} - -func parseEnvAssignments(values []string) (map[string]string, error) { - if len(values) == 0 { - return nil, nil - } - out := make(map[string]string, len(values)) - for _, raw := range values { - raw = strings.TrimSpace(raw) - if raw == "" { - continue - } - key, value, ok := strings.Cut(raw, "=") - key = strings.TrimSpace(key) - if !ok || key == "" { - return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", raw) - } - if _, exists := out[key]; exists { - return nil, fmt.Errorf("duplicate --env key %q", key) - } - out[key] = value - } - return out, nil -} diff --git a/cli/bot/bot_test.go b/cli/bot/bot_test.go deleted file mode 100644 index 6039116d..00000000 --- a/cli/bot/bot_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package bot - -import "testing" - -func TestParseEnvAssignments(t *testing.T) { - t.Parallel() - - got, err := parseEnvAssignments([]string{"GITLAB_TOKEN=secret", "GITLAB_URL=https://gitlab.example.com"}) - if err != nil { - t.Fatalf("parseEnvAssignments() error = %v", err) - } - if got["GITLAB_TOKEN"] != "secret" || got["GITLAB_URL"] != "https://gitlab.example.com" { - t.Fatalf("parseEnvAssignments() = %#v", got) - } -} - -func TestParseEnvAssignmentsRejectsInvalid(t *testing.T) { - t.Parallel() - - if _, err := parseEnvAssignments([]string{"NOT_A_PAIR"}); err == nil { - t.Fatal("parseEnvAssignments() error = nil, want invalid env") - } -} - -func TestParseEnvAssignmentsRejectsDuplicateKey(t *testing.T) { - t.Parallel() - - if _, err := parseEnvAssignments([]string{"A=1", "A=2"}); err == nil { - t.Fatal("parseEnvAssignments() error = nil, want duplicate key") - } -} diff --git a/cli/bot/config.go b/cli/bot/config.go deleted file mode 100644 index 52c3e5e3..00000000 --- a/cli/bot/config.go +++ /dev/null @@ -1,196 +0,0 @@ -package bot - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "os" - "strings" - "text/tabwriter" - - "csgclaw/cli/command" - "csgclaw/internal/apitypes" -) - -const feishuConfigAPIPath = "/api/v1/channels/feishu/config" - -func (c cmd) runConfig(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { - fs := run.NewFlagSet( - "bot config", - run.Program+" bot config --channel feishu (--get|--set|--reload) [flags]", - "Manage bot channel configuration.", - ) - channelName := fs.String("channel", "feishu", "channel name; only feishu supports bot config") - get := fs.Bool("get", false, "get masked channel config") - set := fs.Bool("set", false, "set channel config") - reload := fs.Bool("reload", false, "reload channel config") - botID := fs.String("bot-id", "", "bot id") - appID := fs.String("app-id", "", "Feishu app id") - adminOpenID := fs.String("admin-open-id", "", "Feishu admin open_id") - secretFile := fs.String("app-secret-file", "", "read Feishu app secret from file") - secretEnv := fs.String("app-secret-env", "", "read Feishu app secret from environment variable") - secretStdin := fs.Bool("app-secret-stdin", false, "read Feishu app secret from stdin") - noReload := fs.Bool("no-reload", false, "write config without reloading running server") - if err := fs.Parse(args); err != nil { - return err - } - if len(fs.Args()) != 0 { - return fmt.Errorf("bot config does not accept positional arguments") - } - if normalizeChannel(*channelName) != "feishu" { - return fmt.Errorf("bot config currently supports only --channel feishu") - } - - actions := 0 - for _, enabled := range []bool{*get, *set, *reload} { - if enabled { - actions++ - } - } - if actions != 1 { - return fmt.Errorf("provide exactly one of --get, --set, or --reload") - } - - switch { - case *get: - return c.runConfigGet(ctx, run, globals, *botID) - case *set: - return c.runConfigSet(ctx, run, globals, *botID, *appID, *adminOpenID, *secretFile, *secretEnv, *secretStdin, *noReload) - case *reload: - return c.runConfigReload(ctx, run, globals) - default: - return nil - } -} - -func (c cmd) runConfigGet(ctx context.Context, run *command.Context, globals command.GlobalOptions, botID string) error { - id, err := requireBotID(botID) - if err != nil { - return err - } - values := url.Values{"bot_id": []string{id}} - var resp apitypes.FeishuConfigResponse - if err := run.APIClient(globals).DoJSON(ctx, http.MethodGet, feishuConfigAPIPath+"?"+values.Encode(), nil, &resp); err != nil { - return err - } - return renderConfig(globals.Output, run.Stdout, resp) -} - -func (c cmd) runConfigSet(ctx context.Context, run *command.Context, globals command.GlobalOptions, botID, appID, adminOpenID, secretFile, secretEnv string, secretStdin bool, noReload bool) error { - id, err := requireBotID(botID) - if err != nil { - return err - } - if strings.TrimSpace(appID) == "" { - return fmt.Errorf("bot config --set requires --app-id") - } - secret, err := readSecret(run.Stdin, secretFile, secretEnv, secretStdin) - if err != nil { - return err - } - reload := !noReload - req := apitypes.FeishuConfigRequest{ - BotID: id, - AppID: strings.TrimSpace(appID), - AppSecret: secret, - AdminOpenID: strings.TrimSpace(adminOpenID), - Reload: &reload, - } - var resp apitypes.FeishuConfigResponse - if err := run.APIClient(globals).DoJSON(ctx, http.MethodPut, feishuConfigAPIPath, req, &resp); err != nil { - return err - } - return renderConfig(globals.Output, run.Stdout, resp) -} - -func (c cmd) runConfigReload(ctx context.Context, run *command.Context, globals command.GlobalOptions) error { - var resp apitypes.FeishuConfigReloadResponse - if err := run.APIClient(globals).DoJSON(ctx, http.MethodPost, feishuConfigAPIPath, nil, &resp); err != nil { - return err - } - return renderConfigReload(globals.Output, run.Stdout, resp) -} - -func requireBotID(botID string) (string, error) { - botID = strings.TrimSpace(botID) - if botID == "" { - return "", fmt.Errorf("--bot-id is required") - } - return botID, nil -} - -func readSecret(stdin io.Reader, filePath, envName string, fromStdin bool) (string, error) { - count := 0 - if strings.TrimSpace(filePath) != "" { - count++ - } - if strings.TrimSpace(envName) != "" { - count++ - } - if fromStdin { - count++ - } - if count != 1 { - return "", fmt.Errorf("provide exactly one of --app-secret-file, --app-secret-env, or --app-secret-stdin") - } - var secret string - switch { - case strings.TrimSpace(filePath) != "": - data, err := os.ReadFile(strings.TrimSpace(filePath)) - if err != nil { - return "", fmt.Errorf("read app secret file: %w", err) - } - secret = string(data) - case strings.TrimSpace(envName) != "": - value, ok := os.LookupEnv(strings.TrimSpace(envName)) - if !ok { - return "", fmt.Errorf("environment variable %s is not set", strings.TrimSpace(envName)) - } - secret = value - case fromStdin: - data, err := io.ReadAll(stdin) - if err != nil { - return "", fmt.Errorf("read app secret from stdin: %w", err) - } - secret = string(data) - } - secret = strings.TrimSpace(secret) - if secret == "" { - return "", fmt.Errorf("app secret is empty") - } - return secret, nil -} - -func renderConfig(output string, w io.Writer, cfg apitypes.FeishuConfigResponse) error { - if output == "json" { - return command.WriteJSON(w, cfg) - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "BOT_ID\tCONFIGURED\tAPP_ID\tAPP_SECRET\tADMIN_OPEN_ID\tRELOADED") - fmt.Fprintf(tw, "%s\t%t\t%s\t%s\t%s\t%t\n", cfg.BotID, cfg.Configured, display(cfg.AppID), display(cfg.AppSecret), display(cfg.AdminOpenID), cfg.Reloaded) - return tw.Flush() -} - -func renderConfigReload(output string, w io.Writer, resp apitypes.FeishuConfigReloadResponse) error { - if output == "json" { - return command.WriteJSON(w, resp) - } - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintln(tw, "STATUS\tFEISHU_BOTS") - fmt.Fprintf(tw, "%s\t%s\n", display(resp.Status), display(strings.Join(resp.FeishuBots, ","))) - return tw.Flush() -} - -func normalizeChannel(channelName string) string { - return strings.ToLower(strings.TrimSpace(channelName)) -} - -func display(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - return value -} diff --git a/cli/command/command.go b/cli/command/command.go index 09f8033c..b827123e 100644 --- a/cli/command/command.go +++ b/cli/command/command.go @@ -8,7 +8,6 @@ import ( "io" "strings" "text/tabwriter" - "time" "csgclaw/internal/apiclient" "csgclaw/internal/apitypes" @@ -151,40 +150,6 @@ func RenderAction(output string, w io.Writer, result ActionResult) error { return tw.Flush() } -func RenderBots(output string, w io.Writer, bots []apitypes.Bot) error { - switch output { - case "", "table": - return RenderBotsTable(w, bots) - case "json": - return WriteJSON(w, bots) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - -func RenderCompactBotList(output string, w io.Writer, bots []apitypes.Bot) error { - compact := compactBotList(bots) - switch output { - case "", "table": - return RenderCompactBotsTable(w, compact) - case "json": - return WriteJSON(w, compact) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - -func RenderFullBotList(output string, w io.Writer, bots []apitypes.Bot) error { - switch output { - case "", "table": - return RenderFullBotsTable(w, bots) - case "json": - return WriteJSON(w, bots) - default: - return fmt.Errorf("unsupported output format %q", output) - } -} - func RenderAgents(output string, w io.Writer, agents []apitypes.Agent) error { switch output { case "", "table": @@ -218,6 +183,17 @@ func RenderUsers(output string, w io.Writer, users []apitypes.User) error { } } +func RenderParticipants(output string, w io.Writer, participants []apitypes.Participant) error { + switch output { + case "", "table": + return RenderParticipantsTable(w, participants) + case "json": + return WriteJSON(w, participants) + default: + return fmt.Errorf("unsupported output format %q", output) + } +} + func RenderMessages(output string, w io.Writer, messages []apitypes.Message) error { switch output { case "", "table": @@ -283,76 +259,24 @@ func displayAgentProfile(profile string) string { return displayAgentField(profile) } -func RenderBotsTable(w io.Writer, bots []apitypes.Bot) error { - return RenderCompactBotsTable(w, compactBotList(bots)) -} - -func RenderCompactBotsTable(w io.Writer, bots []compactBot) error { - tw := NewTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tDESCRIPTION\tROLE\tCHANNEL") - for _, b := range bots { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", b.ID, b.Name, displayBotDescription(b.Description), b.Role, b.Channel) - } - return tw.Flush() -} - -func RenderFullBotsTable(w io.Writer, bots []apitypes.Bot) error { +func RenderParticipantsTable(w io.Writer, participants []apitypes.Participant) error { tw := NewTableWriter(w) - fmt.Fprintln(tw, "ID\tNAME\tDESCRIPTION\tROLE\tCHANNEL\tAGENT_ID\tUSER_ID\tAVAILABLE\tRUNTIME_KIND\tCREATED_AT") - for _, b := range bots { - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%t\t%s\t%s\n", - b.ID, - b.Name, - displayBotDescription(b.Description), - b.Role, - b.Channel, - displayBotField(b.AgentID), - displayBotField(b.UserID), - b.Available, - displayBotField(b.RuntimeKind), - displayBotTime(b.CreatedAt), + fmt.Fprintln(tw, "ID\tNAME\tTYPE\tCHANNEL\tAGENT_ID\tCHANNEL_USER\tAPP_REF\tSTATUS") + for _, p := range participants { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", + displayBotField(p.ID), + displayBotField(p.Name), + displayBotField(p.Type), + displayBotField(p.Channel), + displayBotField(p.AgentID), + displayBotField(p.ChannelUserRef), + displayBotField(p.ChannelAppRef), + displayBotField(p.LifecycleStatus), ) } return tw.Flush() } -type compactBot struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Role string `json:"role"` - Channel string `json:"channel"` -} - -func compactBotList(bots []apitypes.Bot) []compactBot { - out := make([]compactBot, 0, len(bots)) - for _, b := range bots { - out = append(out, compactBot{ - ID: b.ID, - Name: b.Name, - Description: b.Description, - Role: b.Role, - Channel: b.Channel, - }) - } - return out -} - -func displayBotDescription(value string) string { - const maxRunes = 40 - - value = strings.TrimSpace(value) - if value == "" { - return "-" - } - - runes := []rune(value) - if len(runes) <= maxRunes { - return value - } - return string(runes[:maxRunes]) + "..." -} - func displayBotField(value string) string { value = strings.TrimSpace(value) if value == "" { @@ -361,13 +285,6 @@ func displayBotField(value string) string { return value } -func displayBotTime(value time.Time) string { - if value.IsZero() { - return "-" - } - return value.UTC().Format(time.RFC3339) -} - func RenderRoomsTable(w io.Writer, rooms []apitypes.Room) error { tw := NewTableWriter(w) fmt.Fprintln(tw, "ID\tTITLE\tDIRECT\tMEMBERS\tMESSAGES") diff --git a/cli/command/command_test.go b/cli/command/command_test.go deleted file mode 100644 index 1f301f35..00000000 --- a/cli/command/command_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package command - -import ( - "bytes" - "strings" - "testing" - "time" - - "csgclaw/internal/apitypes" -) - -func TestRenderCompactBotListJSONOmitsOperationalFields(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-feishu", - Name: "feishu", - Description: "manager bot", - Role: "manager", - Channel: "feishu", - AgentID: "u-manager", - UserID: "ou_manager", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderCompactBotList("json", &stdout, bots); err != nil { - t.Fatalf("RenderCompactBotList() error = %v", err) - } - - out := stdout.String() - for _, want := range []string{`"id": "bot-feishu"`, `"description": "manager bot"`, `"role": "manager"`, `"channel": "feishu"`} { - if !strings.Contains(out, want) { - t.Fatalf("compact JSON = %q, want %s", out, want) - } - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"available"`, `"runtime_kind"`, `"created_at"`} { - if strings.Contains(out, unexpected) { - t.Fatalf("compact JSON = %q, should omit %s", out, unexpected) - } - } -} - -func TestRenderCompactBotListTableUsesCompactColumns(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-feishu", - Name: "feishu", - Role: "manager", - Channel: "feishu", - AgentID: "u-manager", - UserID: "ou_manager", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderCompactBotList("table", &stdout, bots); err != nil { - t.Fatalf("RenderCompactBotList() error = %v", err) - } - - out := stdout.String() - if !strings.Contains(out, "ID") || !strings.Contains(out, "CHANNEL") || !strings.Contains(out, "bot-feishu") { - t.Fatalf("compact table = %q, want compact bot columns", out) - } - for _, unexpected := range []string{"AGENT_ID", "USER_ID", "AVAILABLE", "RUNTIME_KIND", "CREATED_AT", "u-manager", "ou_manager", "codex"} { - if strings.Contains(out, unexpected) { - t.Fatalf("compact table = %q, should omit %s", out, unexpected) - } - } -} - -func TestRenderFullBotListTableIncludesRuntime(t *testing.T) { - bots := []apitypes.Bot{{ - ID: "bot-alice", - Name: "alice", - Role: "worker", - Channel: "csgclaw", - AgentID: "u-alice", - UserID: "u-alice", - Available: true, - RuntimeKind: "codex", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }} - - var stdout bytes.Buffer - if err := RenderFullBotList("table", &stdout, bots); err != nil { - t.Fatalf("RenderFullBotList() error = %v", err) - } - - out := stdout.String() - for _, want := range []string{"AGENT_ID", "USER_ID", "AVAILABLE", "RUNTIME_KIND", "CREATED_AT", "bot-alice", "u-alice", "true", "codex", "2026-04-12T09:00:00Z"} { - if !strings.Contains(out, want) { - t.Fatalf("full table = %q, want %s", out, want) - } - } - if strings.Contains(out, "%!s(MISSING)") { - t.Fatalf("full table = %q, contains missing fmt argument", out) - } -} diff --git a/cli/completion/completion.go b/cli/completion/completion.go index 672cab17..91c04c1c 100644 --- a/cli/completion/completion.go +++ b/cli/completion/completion.go @@ -85,8 +85,9 @@ func FullSpec() CommandSpec { hubSpec(), skillSpec(), modelSpec(), + participantSpec("participant"), + participantSpec("pt"), userSpec(), - botSpec(), roomSpec(), memberSpec(), messageSpec(), @@ -101,7 +102,8 @@ func LiteSpec() CommandSpec { Name: "csgclaw-cli", Flags: liteGlobalFlags(), Children: []CommandSpec{ - botSpec(), + participantSpec("participant"), + participantSpec("pt"), hubSpec(), roomSpec(), memberSpec(), @@ -353,42 +355,45 @@ func userSpec() CommandSpec { } } -func botSpec() CommandSpec { +func participantSpec(name string) CommandSpec { return CommandSpec{ - Name: "bot", - Summary: "Manage bots.", + Name: name, + Summary: "Manage channel participants.", Children: []CommandSpec{ { Name: "list", - Summary: "List bots", - Flags: append(channelFlags(), FlagSpec{Name: "role", TakesValue: true, Values: roleValues()}), + Summary: "List participants", + Flags: append(channelFlags(), + FlagSpec{Name: "type", TakesValue: true, Values: []string{"human", "agent", "notification"}}, + FlagSpec{Name: "agent-id", TakesValue: true}, + ), }, { Name: "create", - Summary: "Create a bot", + Summary: "Create a participant", Flags: append(channelFlags(), FlagSpec{Name: "id", TakesValue: true}, FlagSpec{Name: "name", TakesValue: true}, FlagSpec{Name: "description", TakesValue: true}, + FlagSpec{Name: "type", TakesValue: true, Values: []string{"human", "agent", "notification"}}, + FlagSpec{Name: "channel-user-ref", TakesValue: true}, + FlagSpec{Name: "channel-user-kind", TakesValue: true, Values: []string{"local_user_id", "open_id"}}, + FlagSpec{Name: "channel-app-ref", TakesValue: true}, + FlagSpec{Name: "bind", TakesValue: true, Values: []string{"create", "reuse", "none"}}, + FlagSpec{Name: "agent-id", TakesValue: true}, FlagSpec{Name: "role", TakesValue: true, Values: roleValues()}, + FlagSpec{Name: "runtime", TakesValue: true}, + FlagSpec{Name: "image", TakesValue: true}, + FlagSpec{Name: "from-template", TakesValue: true}, FlagSpec{Name: "model-id", TakesValue: true}, + FlagSpec{Name: "env", TakesValue: true}, ), }, - {Name: "delete", Summary: "Delete a bot", Flags: channelFlags()}, { - Name: "config", - Summary: "Manage bot channel config", - Flags: append(feishuChannelFlags(), - FlagSpec{Name: "get"}, - FlagSpec{Name: "set"}, - FlagSpec{Name: "reload"}, - FlagSpec{Name: "bot-id", TakesValue: true}, - FlagSpec{Name: "app-id", TakesValue: true}, - FlagSpec{Name: "admin-open-id", TakesValue: true}, - FlagSpec{Name: "app-secret-file", TakesValue: true}, - FlagSpec{Name: "app-secret-env", TakesValue: true}, - FlagSpec{Name: "app-secret-stdin"}, - FlagSpec{Name: "no-reload"}, + Name: "delete", + Summary: "Delete a participant", + Flags: append(channelFlags(), + FlagSpec{Name: "delete-agent", TakesValue: true, Values: []string{"if_unreferenced"}}, ), }, }, diff --git a/cli/completion/completion_test.go b/cli/completion/completion_test.go index 468ff2d6..352a2f70 100644 --- a/cli/completion/completion_test.go +++ b/cli/completion/completion_test.go @@ -9,15 +9,15 @@ import ( func TestCompleteFullTopLevel(t *testing.T) { got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", ""}) - assertContainsAll(t, got, "serve", "upgrade", "agent", "hub", "skill", "model", "bot", "completion", "--endpoint", "--config", "-V") - assertContainsNone(t, got, "_serve", "__complete") + assertContainsAll(t, got, "serve", "upgrade", "agent", "hub", "skill", "model", "participant", "pt", "completion", "--endpoint", "--config", "-V") + assertContainsNone(t, got, "bot", "_serve", "__complete") } func TestCompleteLiteTopLevel(t *testing.T) { got := Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", ""}) - assertContainsAll(t, got, "bot", "room", "member", "message", "completion", "--endpoint", "-V") - assertContainsNone(t, got, "serve", "agent", "model", "user", "_serve", "__complete") + assertContainsAll(t, got, "participant", "pt", "room", "member", "message", "completion", "--endpoint", "-V") + assertContainsNone(t, got, "bot", "serve", "agent", "model", "user", "_serve", "__complete") } func TestCompleteSubcommandsAndFlags(t *testing.T) { @@ -39,24 +39,24 @@ func TestCompleteSubcommandsAndFlags(t *testing.T) { got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "team", "task", ""}) assertContainsAll(t, got, "list", "create-batch", "assign", "claim", "claim-next", "update", "--help") - got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "bot", ""}) - assertContainsAll(t, got, "list", "create", "delete", "config") + got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "participant", ""}) + assertContainsAll(t, got, "list", "create", "delete") - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", ""}) - assertContainsAll(t, got, "list", "create", "delete", "config") + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "pt", ""}) + assertContainsAll(t, got, "list", "create", "delete") - got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "bot", "config", "--"}) - assertContainsAll(t, got, "--channel", "--get", "--set", "--reload", "--bot-id", "--app-secret-stdin") + got = Complete(LiteSpec(), "csgclaw-cli", []string{"csgclaw-cli", "participant", "create", "--"}) + assertContainsAll(t, got, "--channel", "--name", "--type", "--bind", "--agent-id") - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "config", "--"}) - assertContainsAll(t, got, "--channel", "--get", "--set", "--reload", "--bot-id", "--app-secret-stdin") + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", ""}) + assertContainsNone(t, got, "list", "create", "delete", "config") } func TestCompleteFlagValues(t *testing.T) { - got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "list", "--channel", ""}) + got := Complete(FullSpec(), "csgclaw", []string{"csgclaw", "participant", "list", "--channel", ""}) assertEqual(t, got, []string{"csgclaw", "feishu"}) - got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "bot", "list", "--channel=f"}) + got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "participant", "list", "--channel=f"}) assertEqual(t, got, []string{"--channel=feishu"}) got = Complete(FullSpec(), "csgclaw", []string{"csgclaw", "model", "auth", "login", "c"}) diff --git a/cli/csgclawcli/app.go b/cli/csgclawcli/app.go index 31c7b80c..77405ad7 100644 --- a/cli/csgclawcli/app.go +++ b/cli/csgclawcli/app.go @@ -9,12 +9,12 @@ import ( "os" "strings" - "csgclaw/cli/bot" "csgclaw/cli/command" completioncmd "csgclaw/cli/completion" hubcmd "csgclaw/cli/hub" "csgclaw/cli/member" "csgclaw/cli/message" + participantcmd "csgclaw/cli/participant" "csgclaw/cli/room" skillcmd "csgclaw/cli/skill" teamcmd "csgclaw/cli/team" @@ -68,7 +68,8 @@ func (a *App) AddCommand(commands ...command.Command) { func (a *App) registerDefaultCommands() { a.AddCommand( - bot.NewCmd(), + participantcmd.NewCmd(), + participantcmd.NewAliasCmd("pt"), hubcmd.NewCmd(), room.NewCmd(), member.NewCmd(), @@ -172,21 +173,23 @@ func consumesValue(arg string) bool { func (a *App) usage() { a.ensureDefaultCommands() - fmt.Fprintln(a.stderr, "csgclaw-cli is a lite CSGClaw CLI for bots, rooms, messages, and teams.") + fmt.Fprintln(a.stderr, "csgclaw-cli is a lite CSGClaw CLI for participants, rooms, messages, and teams.") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Usage:") fmt.Fprintln(a.stderr, " csgclaw-cli [global-flags] [args]") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Available Commands:") - for _, cmd := range a.order { - fmt.Fprintf(a.stderr, " %-8s %s\n", cmd.Name(), cmd.Summary()) + commands := a.visibleCommands() + width := commandNameWidth(commands) + for _, cmd := range commands { + fmt.Fprintf(a.stderr, " %-*s %s\n", width, cmd.Name(), cmd.Summary()) } fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Examples:") fmt.Fprintln(a.stderr, " csgclaw-cli -h") fmt.Fprintln(a.stderr, " csgclaw-cli --version") - fmt.Fprintln(a.stderr, " csgclaw-cli bot list --channel feishu") - fmt.Fprintln(a.stderr, " csgclaw-cli bot config --channel feishu --get --bot-id u-dev") + fmt.Fprintln(a.stderr, " csgclaw-cli participant list --channel feishu") + fmt.Fprintln(a.stderr, " csgclaw-cli pt create --channel feishu --name dev --type agent --bind reuse --agent-id u-dev") fmt.Fprintln(a.stderr, " csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --content hello") fmt.Fprintln(a.stderr, " csgclaw-cli team create --lead-bot-id bot-manager --title release") fmt.Fprintln(a.stderr) @@ -197,6 +200,27 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " --version, -V Print version and exit") } +func (a *App) visibleCommands() []command.Command { + commands := make([]command.Command, 0, len(a.order)) + for _, cmd := range a.order { + if hidden, ok := cmd.(interface{ Hidden() bool }); ok && hidden.Hidden() { + continue + } + commands = append(commands, cmd) + } + return commands +} + +func commandNameWidth(commands []command.Command) int { + width := 8 + for _, cmd := range commands { + if n := len(cmd.Name()); n > width { + width = n + } + } + return width + 1 +} + func (a *App) printVersion(output string) error { version := appversion.Current() if output == "json" { diff --git a/cli/csgclawcli/app_test.go b/cli/csgclawcli/app_test.go index ae4a1a49..b9cf5366 100644 --- a/cli/csgclawcli/app_test.go +++ b/cli/csgclawcli/app_test.go @@ -30,20 +30,21 @@ func TestExecuteExposesOnlyLiteCommands(t *testing.T) { got := stderr.String() for _, want := range []string{ "Available Commands:", - "bot Manage bots", - "hub Discover agent templates.", - "room Manage IM rooms", - "member Manage IM room members", - "message Manage IM messages.", - "team Manage agent teams.", - "skill Discover and install ClawHub skills.", - "completion Generate shell completion scripts.", + "participant Manage channel participants.", + "pt Manage channel participants.", + "hub Discover agent templates.", + "room Manage IM rooms", + "member Manage IM room members", + "message Manage IM messages.", + "team Manage agent teams.", + "skill Discover and install ClawHub skills.", + "completion Generate shell completion scripts.", } { if !strings.Contains(got, want) { t.Fatalf("usage = %q, want substring %q", got, want) } } - for _, notWant := range []string{" agent", " serve", " onboard", " user"} { + for _, notWant := range []string{" agent", " serve", " onboard", " user", "\n bot "} { if strings.Contains(got, notWant) { t.Fatalf("usage = %q, should not include %q", got, notWant) } @@ -104,20 +105,20 @@ func TestExecuteHiddenCompleteUsesLiteCommandSet(t *testing.T) { t.Fatalf("Execute() error = %v", err) } got := stdout.String() - for _, want := range []string{"bot\n", "hub\n", "room\n", "member\n", "message\n", "team\n", "completion\n"} { + for _, want := range []string{"participant\n", "pt\n", "hub\n", "room\n", "member\n", "message\n", "team\n", "completion\n"} { if !strings.Contains(got, want) { t.Fatalf("stdout = %q, want substring %q", got, want) } } - for _, notWant := range []string{"agent\n", "serve\n", "onboard\n", "user\n", "__complete\n"} { + for _, notWant := range []string{"agent\n", "serve\n", "onboard\n", "user\n", "bot\n", "__complete\n"} { if strings.Contains(got, notWant) { t.Fatalf("stdout = %q, should not include %q", got, notWant) } } } -func TestExecuteRejectsFullCsgclawCommands(t *testing.T) { - for _, command := range []string{"agent", "serve", "onboard", "user"} { +func TestExecuteRejectsUnavailableCommands(t *testing.T) { + for _, command := range []string{"agent", "serve", "onboard", "user", "bot"} { t.Run(command, func(t *testing.T) { app := &App{ stdout: &bytes.Buffer{}, @@ -136,7 +137,7 @@ func TestExecuteRejectsFullCsgclawCommands(t *testing.T) { } } -func TestExecuteBotIdentityHelpUsesBotIDSemantics(t *testing.T) { +func TestExecuteCollaborationIdentityHelpUsesParticipantSemantics(t *testing.T) { tests := []struct { name string args []string @@ -145,17 +146,17 @@ func TestExecuteBotIdentityHelpUsesBotIDSemantics(t *testing.T) { { name: "room create", args: []string{"room", "create", "--help"}, - want: []string{"creator bot id", "comma-separated member bot ids"}, + want: []string{"creator participant id", "comma-separated member participant ids"}, }, { name: "member create", args: []string{"member", "create", "--help"}, - want: []string{"bot id to add", "inviter bot id"}, + want: []string{"participant id to add", "inviter participant id"}, }, { name: "message create", args: []string{"message", "create", "--help"}, - want: []string{"sender bot id", "mentioned bot id"}, + want: []string{"sender participant id", "mentioned participant id"}, }, { name: "team create", @@ -237,7 +238,7 @@ func TestExecuteTeamTaskListUsesHTTPClient(t *testing.T) { } } -func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { +func TestExecuteCollaborationIdentityRequiredErrorsUseParticipantSemantics(t *testing.T) { tests := []struct { name string args []string @@ -246,12 +247,12 @@ func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { { name: "member create missing user id", args: []string{"member", "create", "--room-id", "room-1", "--inviter-id", "u-manager"}, - want: "--user-id bot id is required", + want: "--user-id participant id is required", }, { name: "message create missing sender id", args: []string{"message", "create", "--room-id", "room-1", "--content", "hello"}, - want: "--sender-id bot id is required", + want: "--sender-id participant id is required", }, } @@ -274,7 +275,7 @@ func TestExecuteBotIdentityRequiredErrorsUseBotIDSemantics(t *testing.T) { } } -func TestExecuteBotListUsesAPIClient(t *testing.T) { +func TestExecuteParticipantListUsesAPIClient(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -283,24 +284,19 @@ func TestExecuteBotListUsesAPIClient(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "list", "--channel", "feishu"}) if err != nil { t.Fatalf("Execute() error = %v", err) } if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(stdout.String(), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", stdout.String(), unexpected) - } + t.Fatalf("stdout = %q, want JSON participant payload", stdout.String()) } } @@ -318,14 +314,14 @@ func TestExecuteDefaultsToJSONOutputForNonTerminalStdout(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want feishu bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want feishu participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "list", "--channel", "feishu"}); err != nil { + if err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "participant", "list", "--channel", "feishu"}); err != nil { t.Fatalf("Execute() error = %v", err) } got, err := os.ReadFile(stdout.Name()) @@ -333,16 +329,11 @@ func TestExecuteDefaultsToJSONOutputForNonTerminalStdout(t *testing.T) { t.Fatalf("ReadFile(stdout) error = %v", err) } if !strings.Contains(string(got), `"id": "bot-feishu"`) || !strings.Contains(string(got), `"channel": "feishu"`) { - t.Fatalf("stdout = %q, want JSON bot payload", string(got)) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(string(got), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", string(got), unexpected) - } + t.Fatalf("stdout = %q, want JSON participant payload", string(got)) } } -func TestExecuteBotListUsesRoleQuery(t *testing.T) { +func TestExecuteParticipantListUsesTypeQuery(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -351,24 +342,19 @@ func TestExecuteBotListUsesRoleQuery(t *testing.T) { if req.Method != http.MethodGet { t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots?role=manager" { - t.Fatalf("url = %q, want role-filtered bot list route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants?type=agent" { + t.Fatalf("url = %q, want type-filtered participant list route", req.URL.String()) } - return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","role":"manager","channel":"feishu","agent_id":"u-manager","user_id":"fsu-manager","created_at":"2026-04-12T09:00:00Z"}]`), nil + return jsonResponse(http.StatusOK, `[{"id":"bot-feishu","name":"feishu","type":"agent","channel":"feishu","agent_id":"u-manager","channel_user_ref":"fsu-manager","lifecycle_status":"active","created_at":"2026-04-12T09:00:00Z"}]`), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "list", "--channel", "feishu", "--role", "manager"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "list", "--channel", "feishu", "--type", "agent"}) if err != nil { t.Fatalf("Execute() error = %v", err) } - if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"role": "manager"`) { - t.Fatalf("stdout = %q, want JSON bot payload", stdout.String()) - } - for _, unexpected := range []string{`"agent_id"`, `"user_id"`, `"created_at"`} { - if strings.Contains(stdout.String(), unexpected) { - t.Fatalf("stdout = %q, want compact csgclaw-cli bot list without %s", stdout.String(), unexpected) - } + if !strings.Contains(stdout.String(), `"id": "bot-feishu"`) || !strings.Contains(stdout.String(), `"type": "agent"`) { + t.Fatalf("stdout = %q, want JSON participant payload", stdout.String()) } } @@ -380,8 +366,8 @@ func TestExecuteUsesEnvironmentForEndpointAndToken(t *testing.T) { stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://env.example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://env.example.test/api/v1/channels/feishu/bots") + if req.URL.String() != "http://env.example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want %q", req.URL.String(), "http://env.example.test/api/v1/channels/feishu/participants") } if got := req.Header.Get("Authorization"); got != "Bearer env-secret-token" { t.Fatalf("Authorization = %q, want %q", got, "Bearer env-secret-token") @@ -390,7 +376,7 @@ func TestExecuteUsesEnvironmentForEndpointAndToken(t *testing.T) { }), } - if err := app.Execute(context.Background(), []string{"bot", "list", "--channel", "feishu"}); err != nil { + if err := app.Execute(context.Background(), []string{"participant", "list", "--channel", "feishu"}); err != nil { t.Fatalf("Execute() error = %v", err) } } @@ -403,8 +389,8 @@ func TestExecuteFlagsOverrideEnvironmentForEndpointAndToken(t *testing.T) { stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.URL.String() != "http://flag.example.test/api/v1/channels/feishu/bots" { - t.Fatalf("url = %q, want %q", req.URL.String(), "http://flag.example.test/api/v1/channels/feishu/bots") + if req.URL.String() != "http://flag.example.test/api/v1/channels/feishu/participants" { + t.Fatalf("url = %q, want %q", req.URL.String(), "http://flag.example.test/api/v1/channels/feishu/participants") } if got := req.Header.Get("Authorization"); got != "Bearer flag-secret-token" { t.Fatalf("Authorization = %q, want %q", got, "Bearer flag-secret-token") @@ -416,13 +402,13 @@ func TestExecuteFlagsOverrideEnvironmentForEndpointAndToken(t *testing.T) { if err := app.Execute(context.Background(), []string{ "--endpoint", "http://flag.example.test", "--token", "flag-secret-token", - "bot", "list", "--channel", "feishu", + "participant", "list", "--channel", "feishu", }); err != nil { t.Fatalf("Execute() error = %v", err) } } -func TestExecuteBotDeleteUsesAPIClient(t *testing.T) { +func TestExecuteParticipantDeleteUsesAPIClient(t *testing.T) { app := &App{ stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, @@ -430,20 +416,20 @@ func TestExecuteBotDeleteUsesAPIClient(t *testing.T) { if req.Method != http.MethodDelete { t.Fatalf("method = %q, want %q", req.Method, http.MethodDelete) } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/bots/u-alice" { - t.Fatalf("url = %q, want feishu bot delete route", req.URL.String()) + if req.URL.String() != "http://example.test/api/v1/channels/feishu/participants/u-alice" { + t.Fatalf("url = %q, want feishu participant delete route", req.URL.String()) } return jsonResponse(http.StatusNoContent, ``), nil }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "delete", "--channel", "feishu", "u-alice"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "participant", "delete", "--channel", "feishu", "u-alice"}) if err != nil { t.Fatalf("Execute() error = %v", err) } } -func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { +func TestExecuteParticipantDeleteSupportsJSONOutput(t *testing.T) { var stdout bytes.Buffer app := &App{ stdout: &stdout, @@ -456,113 +442,17 @@ func TestExecuteBotDeleteSupportsJSONOutput(t *testing.T) { }), } - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "bot", "delete", "--channel", "feishu", "u-alice"}) + err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--output", "json", "participant", "delete", "--channel", "feishu", "u-alice"}) if err != nil { t.Fatalf("Execute() error = %v", err) } - for _, want := range []string{`"command": "bot"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { + for _, want := range []string{`"command": "participant"`, `"action": "delete"`, `"status": "deleted"`, `"id": "u-alice"`, `"channel": "feishu"`} { if !strings.Contains(stdout.String(), want) { t.Fatalf("stdout = %q, want %s", stdout.String(), want) } } } -func TestExecuteBotConfigSetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdin: strings.NewReader("stdin-secret\n"), - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPut { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPut) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config" { - t.Fatalf("url = %q, want feishu config route", req.URL.String()) - } - if got := req.Header.Get("Authorization"); got != "Bearer token" { - t.Fatalf("Authorization = %q, want bearer token", got) - } - var payload map[string]any - if err := json.NewDecoder(req.Body).Decode(&payload); err != nil { - t.Fatalf("decode request: %v", err) - } - for key, want := range map[string]string{ - "bot_id": "u-dev", - "app_id": "cli_dev", - "app_secret": "stdin-secret", - "admin_open_id": "ou_admin", - } { - if got := payload[key]; got != want { - t.Fatalf("payload[%s] = %#v, want %q; payload=%#v", key, got, want, payload) - } - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present","admin_open_id":"ou_admin","reloaded":true}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "--token", "token", "--output", "json", "bot", "config", "--channel", "feishu", "--set", "--bot-id", "u-dev", "--app-id", "cli_dev", "--admin-open-id", "ou_admin", "--app-secret-stdin"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if strings.Contains(stdout.String(), "stdin-secret") { - t.Fatalf("stdout leaked secret: %s", stdout.String()) - } - if !strings.Contains(stdout.String(), `"app_secret": "present"`) { - t.Fatalf("stdout = %q, want masked secret", stdout.String()) - } -} - -func TestExecuteBotConfigGetUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodGet { - t.Fatalf("method = %q, want %q", req.Method, http.MethodGet) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config?bot_id=u-dev" { - t.Fatalf("url = %q, want feishu config get route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"bot_id":"u-dev","configured":true,"app_id":"cli_dev","app_secret":"present"}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--get", "--bot-id", "u-dev"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "u-dev") || !strings.Contains(stdout.String(), "present") { - t.Fatalf("stdout = %s, want bot and masked secret", stdout.String()) - } -} - -func TestExecuteBotConfigReloadUsesFeishuConfigRoute(t *testing.T) { - var stdout bytes.Buffer - app := &App{ - stdout: &stdout, - stderr: &bytes.Buffer{}, - httpClient: roundTripFunc(func(req *http.Request) (*http.Response, error) { - if req.Method != http.MethodPost { - t.Fatalf("method = %q, want %q", req.Method, http.MethodPost) - } - if req.URL.String() != "http://example.test/api/v1/channels/feishu/config" { - t.Fatalf("url = %q, want feishu config reload route", req.URL.String()) - } - return jsonResponse(http.StatusOK, `{"status":"reloaded","feishu_bots":["u-dev"]}`), nil - }), - } - - err := app.Execute(context.Background(), []string{"--endpoint", "http://example.test", "bot", "config", "--channel", "feishu", "--reload"}) - if err != nil { - t.Fatalf("Execute() error = %v", err) - } - if !strings.Contains(stdout.String(), "reloaded") || !strings.Contains(stdout.String(), "u-dev") { - t.Fatalf("stdout = %s, want reload result", stdout.String()) - } -} - func TestExecuteRoomCreateUsesChannelRoute(t *testing.T) { var stdout bytes.Buffer app := &App{ diff --git a/cli/http_client.go b/cli/http_client.go index 33ca8728..441c470d 100644 --- a/cli/http_client.go +++ b/cli/http_client.go @@ -115,10 +115,6 @@ func renderAgentsTable(w io.Writer, agents []apitypes.Agent) error { return tw.Flush() } -func renderBotsTable(w io.Writer, bots []apitypes.Bot) error { - return command.RenderBotsTable(w, bots) -} - func displayAgentField(value string) string { value = strings.TrimSpace(value) if value == "" { diff --git a/cli/member/member.go b/cli/member/member.go index d66f191b..5cb581f4 100644 --- a/cli/member/member.go +++ b/cli/member/member.go @@ -73,8 +73,8 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, fs := run.NewFlagSet("member create", run.Program+" member create [flags]", "Add a member to a room.") channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") roomID := fs.String("room-id", "", "target room id") - userID := fs.String("user-id", "", "bot id to add") - inviterID := fs.String("inviter-id", "", "inviter bot id") + userID := fs.String("user-id", "", "participant id to add") + inviterID := fs.String("inviter-id", "", "inviter participant id") locale := fs.String("locale", "", "room locale") if err := fs.Parse(args); err != nil { return err @@ -83,7 +83,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, return fmt.Errorf("member create does not accept positional arguments") } if *userID == "" { - return fmt.Errorf("--user-id bot id is required") + return fmt.Errorf("--user-id participant id is required") } room, err := run.APIClient(globals).AddRoomMemberByChannel(ctx, *channelName, apitypes.AddRoomMembersRequest{ diff --git a/cli/message/message.go b/cli/message/message.go index 981bd673..3767085b 100644 --- a/cli/message/message.go +++ b/cli/message/message.go @@ -76,9 +76,9 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, fs := run.NewFlagSet("message create", run.Program+" message create [flags]", "Create a message.") channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") roomID := fs.String("room-id", "", "target room id") - senderID := fs.String("sender-id", "", "sender bot id") + senderID := fs.String("sender-id", "", "sender participant id") content := fs.String("content", "", "message content") - mentionID := fs.String("mention-id", "", "mentioned bot id") + mentionID := fs.String("mention-id", "", "mentioned participant id") if err := fs.Parse(args); err != nil { return err } @@ -89,7 +89,7 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, return fmt.Errorf("room_id is required") } if *senderID == "" { - return fmt.Errorf("--sender-id bot id is required") + return fmt.Errorf("--sender-id participant id is required") } if *content == "" { return fmt.Errorf("content is required") diff --git a/cli/participant/participant.go b/cli/participant/participant.go new file mode 100644 index 00000000..13ca609a --- /dev/null +++ b/cli/participant/participant.go @@ -0,0 +1,223 @@ +package participant + +import ( + "context" + "flag" + "fmt" + "strings" + + "csgclaw/cli/command" + "csgclaw/internal/agent" + participantpkg "csgclaw/internal/participant" +) + +type cmd struct { + name string +} + +func NewCmd() command.Command { + return cmd{name: "participant"} +} + +func NewAliasCmd(name string) command.Command { + name = strings.TrimSpace(name) + if name == "" { + name = "pt" + } + return cmd{name: name} +} + +func (c cmd) Name() string { + return c.name +} + +func (cmd) Summary() string { + return "Manage channel participants." +} + +func (c cmd) Run(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + if len(args) == 0 { + c.usage(run) + return flag.ErrHelp + } + if command.IsHelpArg(args[0]) { + c.usage(run) + return flag.ErrHelp + } + + switch args[0] { + case "list": + return c.runList(ctx, run, args[1:], globals) + case "create": + return c.runCreate(ctx, run, args[1:], globals) + case "delete": + return c.runDelete(ctx, run, args[1:], globals) + default: + c.usage(run) + return fmt.Errorf("unknown %s subcommand %q", c.Name(), args[0]) + } +} + +func (c cmd) usage(run *command.Context) { + subcommands := []string{ + "list List participants", + "create Create a participant", + "delete Delete a participant", + } + run.UsageCommandGroup(c, run.Program+" "+c.Name()+" [flags]", subcommands) +} + +func (c cmd) runList(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" list", run.Program+" "+c.Name()+" list [flags]", "List participants.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + participantType := fs.String("type", "", "participant type: human, agent, or notification") + agentID := fs.String("agent-id", "", "filter by bound agent id") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("%s list does not accept positional arguments", c.Name()) + } + + items, err := run.APIClient(globals).ListParticipants(ctx, *channelName, *participantType, *agentID) + if err != nil { + return err + } + return command.RenderParticipants(globals.Output, run.Stdout, items) +} + +func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" create", run.Program+" "+c.Name()+" create [flags]", "Create a participant.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + id := fs.String("id", "", "participant id") + name := fs.String("name", "", "participant display name") + description := fs.String("description", "", "agent description for bind create and participant metadata") + participantType := fs.String("type", participantpkg.TypeAgent, "participant type: human, agent, or notification") + channelUserRef := fs.String("channel-user-ref", "", "channel user identity such as local user id or Feishu open_id") + channelUserKind := fs.String("channel-user-kind", "", "channel user identity kind such as local_user_id or open_id") + channelAppRef := fs.String("channel-app-ref", "", "channel app/config reference such as Feishu app_id") + bindMode := fs.String("bind", participantpkg.BindingModeNone, "agent binding mode: create, reuse, or none") + agentID := fs.String("agent-id", "", "agent id for bind reuse, or optional id for bind create") + role := fs.String("role", "", "agent role for bind create") + runtimeKind := fs.String("runtime", "", "agent runtime kind for bind create") + image := fs.String("image", "", "agent image for bind create") + fromTemplate := fs.String("from-template", "", "hub template for bind create") + modelID := fs.String("model-id", "", "agent model id for bind create") + var envValues envFlag + fs.Var(&envValues, "env", "agent image environment variable as KEY=VALUE (repeatable)") + if err := fs.Parse(args); err != nil { + return err + } + if len(fs.Args()) != 0 { + return fmt.Errorf("%s create does not accept positional arguments", c.Name()) + } + if strings.TrimSpace(*name) == "" { + return fmt.Errorf("%s create requires --name", c.Name()) + } + envMap, err := parseEnvAssignments(envValues) + if err != nil { + return err + } + + req := participantpkg.CreateRequest{ + ID: *id, + Channel: *channelName, + Type: *participantType, + Name: *name, + ChannelAppRef: *channelAppRef, + ChannelUser: participantpkg.ChannelUserSpec{ + Ref: *channelUserRef, + Kind: *channelUserKind, + }, + AgentBinding: participantpkg.AgentBindingSpec{ + Mode: *bindMode, + AgentID: *agentID, + }, + } + if strings.TrimSpace(*description) != "" { + req.Metadata = map[string]any{"description": strings.TrimSpace(*description)} + } + if strings.EqualFold(strings.TrimSpace(*bindMode), participantpkg.BindingModeCreate) { + spec := agent.CreateAgentSpec{ + ID: *agentID, + Name: *name, + Description: *description, + Role: *role, + RuntimeKind: *runtimeKind, + Image: *image, + FromTemplate: *fromTemplate, + } + if strings.TrimSpace(*modelID) != "" { + spec.AgentProfile.ModelID = *modelID + } + if len(envMap) > 0 { + spec.AgentProfile.Env = envMap + } + req.AgentBinding.Agent = &spec + } + + created, err := run.APIClient(globals).CreateParticipant(ctx, req) + if err != nil { + return err + } + return command.RenderParticipants(globals.Output, run.Stdout, []participantpkg.Participant{created}) +} + +type envFlag []string + +func (e *envFlag) String() string { + return strings.Join(*e, ",") +} + +func (e *envFlag) Set(value string) error { + *e = append(*e, value) + return nil +} + +func parseEnvAssignments(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + out := make(map[string]string, len(values)) + for _, raw := range values { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + key, value, ok := strings.Cut(raw, "=") + key = strings.TrimSpace(key) + if !ok || key == "" { + return nil, fmt.Errorf("invalid --env %q: expected KEY=VALUE", raw) + } + if _, exists := out[key]; exists { + return nil, fmt.Errorf("duplicate --env key %q", key) + } + out[key] = value + } + return out, nil +} + +func (c cmd) runDelete(ctx context.Context, run *command.Context, args []string, globals command.GlobalOptions) error { + fs := run.NewFlagSet(c.Name()+" delete", run.Program+" "+c.Name()+" delete [flags]", "Delete a participant.") + channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") + deleteAgent := fs.String("delete-agent", "", "agent cleanup mode; supported: if_unreferenced") + if err := fs.Parse(args); err != nil { + return err + } + + rest := fs.Args() + if len(rest) != 1 { + return fmt.Errorf("%s delete requires exactly one id", c.Name()) + } + if err := run.APIClient(globals).DeleteParticipant(ctx, *channelName, rest[0], *deleteAgent); err != nil { + return err + } + return command.RenderAction(globals.Output, run.Stdout, command.ActionResult{ + Command: c.Name(), + Action: "delete", + Status: "deleted", + ID: rest[0], + Channel: *channelName, + Message: fmt.Sprintf("deleted %s participant %s", *channelName, rest[0]), + }) +} diff --git a/cli/room/room.go b/cli/room/room.go index 77aca5ee..5f5454a6 100644 --- a/cli/room/room.go +++ b/cli/room/room.go @@ -76,8 +76,8 @@ func (c cmd) runCreate(ctx context.Context, run *command.Context, args []string, channelName := fs.String("channel", "csgclaw", "channel name: csgclaw or feishu") title := fs.String("title", "", "room title") description := fs.String("description", "", "room description") - creatorID := fs.String("creator-id", "", "creator bot id") - memberIDs := fs.String("member-ids", "", "comma-separated member bot ids") + creatorID := fs.String("creator-id", "", "creator participant id") + memberIDs := fs.String("member-ids", "", "comma-separated member participant ids") locale := fs.String("locale", "", "room locale") if err := fs.Parse(args); err != nil { return err diff --git a/cli/serve/serve.go b/cli/serve/serve.go index a4dfc2c8..d7025e25 100644 --- a/cli/serve/serve.go +++ b/cli/serve/serve.go @@ -39,6 +39,7 @@ import ( "csgclaw/internal/llm" "csgclaw/internal/modelprovider" internalonboard "csgclaw/internal/onboard" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" runtimecodex "csgclaw/internal/runtime/codex" "csgclaw/internal/sandboxproviders" @@ -63,8 +64,14 @@ var ( ShutdownCLIProxy = func(ctx context.Context) error { return cliproxy.Default().Shutdown(ctx) } - DetectBootstrapState = internalonboard.DetectState - EnsureBootstrapState = internalonboard.EnsureState + DetectBootstrapState = internalonboard.DetectState + EnsureBootstrapState = internalonboard.EnsureState + EnsureBootstrapManager = func(ctx context.Context, svc *agent.Service) error { + if svc == nil { + return nil + } + return svc.EnsureBootstrapManager(ctx, false) + } StartConfiguredAgents = func(ctx context.Context, svc *agent.Service) error { if svc == nil { return nil @@ -434,6 +441,10 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co if botSvc != nil { botSvc.SetDependencies(svc, imSvc, feishuSvc) } + participantSvc, err := newParticipantService(svc, imSvc) + if err != nil { + return err + } llmSvc, err := NewLLMService(cfg, svc) if err != nil { return err @@ -479,6 +490,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co Service: svc, Hub: hubSvc, Bot: botSvc, + Participant: participantSvc, IM: imSvc, IMBus: imBus, BotBridge: im.NewBotBridge(cfg.Server.AccessToken), @@ -493,7 +505,7 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co NoAuth: cfg.Server.NoAuth, Context: ctx, OnReady: func(handler *api.Handler, router chi.Router) { - deliver := channelwiring.WireNotificationBotPull(ctx, botSvc, imSvc, apiURL, cfg.Server.AccessToken) + deliver := channelwiring.WireNotificationBotPull(ctx, botSvc, participantSvc, imSvc, apiURL, cfg.Server.AccessToken) handler.SetNotificationDeliver(deliver) if output != "json" && run != nil { go func() { @@ -509,6 +521,9 @@ func startServerWithConfigPath(ctx context.Context, run *command.Context, cfg co }() } go func() { + if err := EnsureBootstrapManager(ctx, svc); err != nil { + slog.Warn("bootstrap manager failed to start", "error", err) + } if err := StartConfiguredAgents(ctx, svc); err != nil { slog.Warn("some configured agents failed to start", "error", err) } @@ -910,11 +925,7 @@ func (m *serveCodexBridgeManager) Start(ctx context.Context) error { startErr = errors.Join(startErr, fmt.Errorf("%s: %w", a.Name, err)) continue } - if err := m.bridge.StartBot(ctx, codexbridge.Binding{ - BotID: a.ID, - RuntimeID: strings.TrimSpace(a.RuntimeID), - SessionID: session.SessionID, - }); err != nil { + if err := m.bridge.StartBot(ctx, codexBridgeBindingForAgent(a, session.SessionID)); err != nil { startErr = errors.Join(startErr, fmt.Errorf("%s: %w", a.Name, err)) } } @@ -940,12 +951,16 @@ func (m *serveCodexBridgeManager) EnsureAgent(ctx context.Context, a agent.Agent // Force a fresh bot-event subscription even when the binding is unchanged. // This repairs cases where the bridge worker exists but missed its initial // subscription window and would otherwise be treated as a no-op restart. - m.bridge.StopBot(a.ID) - return m.bridge.StartBot(ctx, codexbridge.Binding{ - BotID: a.ID, + m.stopAgentBridge(a) + return m.bridge.StartBot(ctx, codexBridgeBindingForAgent(a, session.SessionID)) +} + +func codexBridgeBindingForAgent(a agent.Agent, sessionID string) codexbridge.Binding { + return codexbridge.Binding{ + BotID: agent.ParticipantIDForAgent(a.Name, a.ID), RuntimeID: strings.TrimSpace(a.RuntimeID), - SessionID: session.SessionID, - }) + SessionID: strings.TrimSpace(sessionID), + } } func (m *serveCodexBridgeManager) beginEnsure(agentID string) bool { @@ -976,7 +991,22 @@ func (m *serveCodexBridgeManager) StopAgent(agentID string) { if m == nil || m.bridge == nil { return } - m.bridge.StopBot(agentID) + m.bridge.StopBot(strings.TrimSpace(agentID)) + participantID := agent.ParticipantIDForAgent("", agentID) + if participantID != strings.TrimSpace(agentID) { + m.bridge.StopBot(participantID) + } +} + +func (m *serveCodexBridgeManager) stopAgentBridge(a agent.Agent) { + if m == nil || m.bridge == nil { + return + } + m.bridge.StopBot(strings.TrimSpace(a.ID)) + participantID := agent.ParticipantIDForAgent(a.Name, a.ID) + if participantID != strings.TrimSpace(a.ID) { + m.bridge.StopBot(participantID) + } } func (m *serveCodexBridgeManager) Close() { @@ -1043,15 +1073,27 @@ func newIMService(bus *im.Bus) (*im.Service, error) { } func newBotService() (*bot.Service, error) { + store, err := bot.NewStore("") + if err != nil { + return nil, err + } + return bot.NewService(store) +} + +func newParticipantService(agentSvc *agent.Service, imSvc *im.Service) (*participant.Service, error) { imStatePath, err := config.DefaultIMStatePath() if err != nil { return nil, err } - store, err := bot.NewStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { return nil, err } - return bot.NewService(store) + return participant.NewService( + store, + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ), nil } func newTeamService(imSvc *im.Service) (*team.Service, team.TeamChannelAdapter, error) { diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index c98af351..7aee1115 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -464,6 +464,7 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { origNewIMService := NewIMService origNewFeishuService := NewFeishuService origNewLLMService := NewLLMService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents origNewCodexBridgeManager := NewCodexBridgeManager origEnsureCLIProxy := EnsureCLIProxy @@ -475,6 +476,7 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { NewIMService = origNewIMService NewFeishuService = origNewFeishuService NewLLMService = origNewLLMService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents NewCodexBridgeManager = origNewCodexBridgeManager EnsureCLIProxy = origEnsureCLIProxy @@ -510,7 +512,16 @@ func TestServeForegroundPassesContextToServer(t *testing.T) { startCalled := make(chan struct{}) releaseStart := make(chan struct{}) startReturned := make(chan struct{}) - startErrors := make(chan string, 4) + startErrors := make(chan string, 6) + EnsureBootstrapManager = func(gotCtx context.Context, gotSvc *agent.Service) error { + if gotCtx != ctx { + startErrors <- fmt.Sprintf("EnsureBootstrapManager context = %v, want %v", gotCtx, ctx) + } + if gotSvc != svc { + startErrors <- fmt.Sprintf("EnsureBootstrapManager service = %p, want %p", gotSvc, svc) + } + return nil + } StartConfiguredAgents = func(gotCtx context.Context, gotSvc *agent.Service) error { defer close(startReturned) if gotCtx != ctx { @@ -752,6 +763,44 @@ func TestServeForegroundStartsConfiguredAgentsOnReady(t *testing.T) { } } +func TestServeForegroundEnsuresBootstrapManagerBeforeConfiguredAgents(t *testing.T) { + restore := stubServeDependencies(t) + defer restore() + + calls := make(chan string, 2) + EnsureBootstrapManager = func(context.Context, *agent.Service) error { + calls <- "manager" + return nil + } + StartConfiguredAgents = func(context.Context, *agent.Service) error { + calls <- "configured-agents" + return nil + } + RunServer = func(opts server.Options) error { + if opts.OnReady == nil { + return fmt.Errorf("OnReady is nil") + } + opts.OnReady(nil, nil) + return nil + } + + if err := serveForeground(context.Background(), testContext(), config.Config{Server: config.ServerConfig{ListenAddr: "127.0.0.1:18080"}}, "json"); err != nil { + t.Fatalf("serveForeground() error = %v", err) + } + got := make([]string, 0, 2) + for len(got) < 2 { + select { + case call := <-calls: + got = append(got, call) + case <-time.After(time.Second): + t.Fatalf("startup calls = %v, want manager before configured-agents", got) + } + } + if want := []string{"manager", "configured-agents"}; fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("startup calls = %v, want %v", got, want) + } +} + func TestServeForegroundPassesConfigPathToServer(t *testing.T) { restore := stubServeDependencies(t) defer restore() @@ -900,13 +949,31 @@ func TestShouldStartCodexBridge(t *testing.T) { } } +func TestCodexBridgeBindingUsesParticipantIDForWorker(t *testing.T) { + binding := codexBridgeBindingForAgent(agent.Agent{ + ID: "u-agent-3l6htd", + Name: "dev", + RuntimeKind: agent.RuntimeKindCodex, + RuntimeID: "rt-u-agent-3l6htd", + }, "sess-dev") + + if binding.BotID != "agent-3l6htd" { + t.Fatalf("BotID = %q, want participant ID agent-3l6htd", binding.BotID) + } + if binding.RuntimeID != "rt-u-agent-3l6htd" || binding.SessionID != "sess-dev" { + t.Fatalf("binding = %+v, want runtime/session preserved", binding) + } +} + func TestServeForegroundPreservesBootstrapDefaultTemplates(t *testing.T) { origRunServer := RunServer origNewAgentService := NewAgentService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents t.Cleanup(func() { RunServer = origRunServer NewAgentService = origNewAgentService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents }) RunServer = func(opts server.Options) error { @@ -915,6 +982,7 @@ func TestServeForegroundPreservesBootstrapDefaultTemplates(t *testing.T) { } return nil } + EnsureBootstrapManager = func(context.Context, *agent.Service) error { return nil } StartConfiguredAgents = func(context.Context, *agent.Service) error { return nil } cfg := config.Config{ @@ -1310,6 +1378,7 @@ func stubServeDependencies(t *testing.T) func() { origNewIMService := NewIMService origNewFeishuService := NewFeishuService origNewLLMService := NewLLMService + origEnsureBootstrapManager := EnsureBootstrapManager origStartConfiguredAgents := StartConfiguredAgents origNewCodexBridgeManager := NewCodexBridgeManager origEnsureCLIProxy := EnsureCLIProxy @@ -1332,6 +1401,7 @@ func stubServeDependencies(t *testing.T) func() { NewIMService = func(*im.Bus) (*im.Service, error) { return nil, nil } NewFeishuService = func(feishu.Provider) (*feishu.Service, error) { return nil, nil } NewLLMService = func(config.Config, *agent.Service) (*llm.Service, error) { return nil, nil } + EnsureBootstrapManager = func(context.Context, *agent.Service) error { return nil } StartConfiguredAgents = func(context.Context, *agent.Service) error { return nil } NewCodexBridgeManager = func(config.Config, *agent.Service) (codexBridgeManager, error) { return nil, nil } EnsureCLIProxy = func(context.Context) error { return nil } @@ -1356,6 +1426,7 @@ func stubServeDependencies(t *testing.T) func() { NewIMService = origNewIMService NewFeishuService = origNewFeishuService NewLLMService = origNewLLMService + EnsureBootstrapManager = origEnsureBootstrapManager StartConfiguredAgents = origStartConfiguredAgents NewCodexBridgeManager = origNewCodexBridgeManager EnsureCLIProxy = origEnsureCLIProxy diff --git a/docs/agent_teams.md b/docs/agent_teams.md index c2ba092b..ea979fdf 100644 --- a/docs/agent_teams.md +++ b/docs/agent_teams.md @@ -673,7 +673,7 @@ GET /api/v1/teams/{team_id}/events CLI 复用现有 bot 身份环境变量: ```bash -PICOCLAW_CHANNELS_CSGCLAW_BOT_ID=bot-alice +PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID=bot-alice ``` 执行任务期间,`team_id` 从 `claim` / `claim-next` / `task list` 的响应或显式 `--team` 参数获取。 @@ -684,7 +684,7 @@ CLI 默认根据 stdout 自动选择输出: - stdout 被 pipe 或脚本消费:输出 JSON,方便 agent 和脚本解析; - 需要显式覆盖时使用 `--output json|table`。 -`--bot` 参数默认读取 `PICOCLAW_CHANNELS_CSGCLAW_BOT_ID`,只有调试或 manager 代操作时才显式传入。 +`--bot` 参数默认读取 `PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID`,只有调试或 manager 代操作时才显式传入。 ### 10.2 命令集分层 diff --git a/docs/api.md b/docs/api.md index 284222a1..81dc674c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,27 +8,25 @@ This document is generated from the HTTP routes and behaviors currently implemen - Time fields use RFC3339 / ISO8601 - Most non-streaming errors are returned as plain-text response bodies - SSE endpoints use `text/event-stream` -- The current API is mainly grouped into 4 areas: +- The current API is mainly grouped into 3 areas: - Core API: `/api/v1/*` - - Channel API: `/api/v1/channels/*` - - Bot compatibility API: `/api/bots/*` + - Channel and participant API: `/api/v1/channels/*` - Health check: `/healthz` ## Authentication - Most `/api/v1/*` endpoints do not require authentication by default - The following endpoints require `Authorization: Bearer `, where the token is the server access token: - - `GET /api/v1/channels/feishu/bots/{id}/events` - - `GET /api/bots/{id}/events` - - `POST /api/bots/{id}/messages/send` - - `GET /api/bots/{id}/llm/models` - - `GET /api/bots/{id}/llm/v1/models` - - `POST /api/bots/{id}/llm/chat/completions` - - `POST /api/bots/{id}/llm/v1/chat/completions` - - `GET /api/bots/{id}/llm/responses` - - `GET /api/bots/{id}/llm/v1/responses` - - `POST /api/bots/{id}/llm/responses` - - `POST /api/bots/{id}/llm/v1/responses` + - `GET /api/v1/channels/{channel}/participants/{id}/events` + - `POST /api/v1/channels/csgclaw/participants/{id}/messages` + - `GET /api/v1/agents/{id}/llm/models` + - `GET /api/v1/agents/{id}/llm/v1/models` + - `POST /api/v1/agents/{id}/llm/chat/completions` + - `POST /api/v1/agents/{id}/llm/v1/chat/completions` + - `POST /api/v1/agents/{id}/llm/responses` + - `POST /api/v1/agents/{id}/llm/v1/responses` + - `GET /api/v1/agents/{id}/llm/responses` + - `GET /api/v1/agents/{id}/llm/v1/responses` - If the server runs with `no_auth`, the checks above are skipped ## Health Check @@ -88,13 +86,15 @@ On success, returns `202 Accepted`: Returns `503 Service Unavailable` if the upgrade manager is not configured. -## Bot Management API +## Participant API -These endpoints are exposed under the channel API namespace and are still backed by the unified `internal/bot` service. The route shape is channel-scoped, but bot lifecycle orchestration is not split into separate per-channel bot services. `role` only supports `manager` and `worker`, and `channel` only supports `csgclaw` and `feishu`. +Participants are channel-scoped identities used by rooms, messages, mentions, +notifications, and runtime bridges. A participant can represent a human, an +agent-backed channel identity, or a notification sender. -### `GET /api/v1/channels/{channel}/bots` +### `GET /api/v1/channels/{channel}/participants` -Returns the bot list for the specified channel. +Returns participants for the specified channel. Path parameters: @@ -102,29 +102,36 @@ Path parameters: Optional query parameters: -- `role` +- `type`: `human`, `agent`, or `notification` +- `agent_id` Response fields: - `id` -- `name` -- `description` -- `role` - `channel` +- `type` +- `name` +- `avatar` +- `channel_user_ref` +- `channel_user_kind` +- `channel_app_ref` - `agent_id` -- `user_id` -- `available` -- `runtime_kind` +- `lifecycle_status` +- `presence` +- `mentionable` +- `metadata` - `created_at` +- `updated_at` Examples: -- `GET /api/v1/channels/csgclaw/bots` -- `GET /api/v1/channels/feishu/bots?role=worker` +- `GET /api/v1/channels/csgclaw/participants` +- `GET /api/v1/channels/csgclaw/participants?type=notification` +- `GET /api/v1/channels/feishu/participants?agent_id=u-worker` -### `POST /api/v1/channels/{channel}/bots` +### `POST /api/v1/channels/{channel}/participants` -Creates a bot in the specified channel. +Creates a participant in the specified channel. Path parameters: @@ -134,42 +141,59 @@ Example request body: ```json { - "id": "u-alice", - "name": "alice", - "role": "worker", - "runtime_kind": "codex", - "from_template": "local.review-bot" + "id": "qa", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "from_template": "builtin.picoclaw-worker" + } + } } ``` Notes: +- `type` is required and must be `human`, `agent`, or `notification` - `name` is required -- `role` is required and must be either `manager` or `worker` - The effective channel comes from the route path rather than the request body -- A `worker` bot is associated with a backend agent -- `manager` and `worker` creation behavior can differ by channel +- `agent` participants can create or reuse an Agent through `agent_binding` +- `human` and `notification` participants do not create runtime agents +- For `csgclaw`, `channel_user.ref` is a local IM user ID +- For `feishu`, `channel_user.ref` is the channel-native open ID Examples: -- `POST /api/v1/channels/csgclaw/bots` -- `POST /api/v1/channels/feishu/bots` +- `POST /api/v1/channels/csgclaw/participants` +- `POST /api/v1/channels/feishu/participants` -### `DELETE /api/v1/channels/{channel}/bots/{id}` +### `GET /api/v1/channels/{channel}/participants/{id}` -Deletes the specified bot in the specified channel. +Returns one participant. -Path parameters: +### `PATCH /api/v1/channels/{channel}/participants/{id}` -- `channel`: `csgclaw` or `feishu` -- `id`: bot ID +Updates editable participant fields such as `name`, `avatar`, `mentionable`, and +`metadata`. + +### `DELETE /api/v1/channels/{channel}/participants/{id}` + +Deletes the specified participant in the specified channel. Returns `204 No Content` on success. Examples: -- `DELETE /api/v1/channels/csgclaw/bots/u-alice` -- `DELETE /api/v1/channels/feishu/bots/u-alice` +- `DELETE /api/v1/channels/csgclaw/participants/qa` +- `DELETE /api/v1/channels/feishu/participants/qa` ## Agent API @@ -533,26 +557,6 @@ Notes: - Missing `provider` returns `400` - Login failure returns `502 Bad Gateway` -### `POST /api/v1/cliproxy/auth/logout` - -Disables local provider auth. - -Request body: - -```json -{ - "provider": "codex" -} -``` - -Returns the current provider auth status on success. - -Notes: - -- Missing `provider` returns `400` -- Logout failure returns `502 Bad Gateway` -- Logout blocks immediate auto-import from the same Codex home auth or Claude Keychain entry. - ## Bootstrap Config API ### `GET /api/v1/config/bootstrap` @@ -959,17 +963,17 @@ Example response: } ``` -### Bot Events +### Participant Events -#### `GET /api/v1/channels/feishu/bots/{id}/events` +#### `GET /api/v1/channels/feishu/participants/{id}/events` -Subscribes to mention events for the specified bot in Feishu. +Subscribes to mention events for the specified participant in Feishu. Characteristics: - Requires Bearer Token - Returns `text/event-stream` -- Only forwards events whose message mentions the bot open_id +- Only forwards events whose message mentions the participant open_id - Writes `: connected` immediately after the stream is established ### Users @@ -1032,16 +1036,18 @@ Example send-message request: } ``` -## Bot Compatibility API +## Runtime Bridge API -These endpoints live under `/api/bots/{id}` and exist for compatibility with the older PicoClaw bot integration. +Runtime clients use participant-scoped routes for channel messages and +agent-scoped routes for LLM provider traffic. The legacy `/api/bots/*` routes +are not registered. -For thread/session isolation rules used by the bot and Codex bridges, see +For thread/session isolation rules used by runtime and Codex bridges, see [im-threads.md](./im-threads.md). -### `GET /api/bots/{id}/events` +### `GET /api/v1/channels/{channel}/participants/{id}/events` -Subscribes to the bot event stream. +Subscribes to the participant event stream. Characteristics: @@ -1062,13 +1068,13 @@ data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"ro For thread replies, `thread_root_id` is the root message ID and `thread_context` carries the deterministic hidden context captured when the -thread was started. Bot/LLM bridges use it as prompt context; it is not a list +thread was started. Runtime/LLM bridges use it as prompt context; it is not a list of thread replies. PicoClaw-native clients can use `context.topic_id` as the same thread/session identifier. -### `POST /api/bots/{id}/messages/send` +### `POST /api/v1/channels/csgclaw/participants/{id}/messages` -Sends a message through the bot compatibility channel. +Sends a message as the specified local CSGClaw participant. Example request body: @@ -1081,9 +1087,9 @@ Example request body: ``` `thread_root_id`, `topic_id`, and `context.topic_id` are optional thread/topic -identifiers. When one is present, the bot response is sent as a reply inside +identifiers. When one is present, the participant response is sent as a reply inside that IM thread. When all are omitted, the response is sent as a top-level room/DM -message; the server does not infer a thread from the bot's most recent room +message; the server does not infer a thread from the participant's most recent room event. PicoClaw outbound message shape is also accepted: @@ -1100,9 +1106,9 @@ PicoClaw outbound message shape is also accepted: } ``` -### `GET /api/bots/{id}/llm/models` +### `GET /api/v1/agents/{id}/llm/models` -### `GET /api/bots/{id}/llm/v1/models` +### `GET /api/v1/agents/{id}/llm/v1/models` Forwards model-list requests to the LLM bridge. @@ -1111,9 +1117,9 @@ Notes: - Requires Bearer Token - Response content type and body are determined by the upstream bridge -### `POST /api/bots/{id}/llm/chat/completions` +### `POST /api/v1/agents/{id}/llm/chat/completions` -### `POST /api/bots/{id}/llm/v1/chat/completions` +### `POST /api/v1/agents/{id}/llm/v1/chat/completions` Forwards chat-completions requests to the LLM bridge. @@ -1134,17 +1140,17 @@ Notes: } ``` -### `POST /api/bots/{id}/llm/responses` +### `POST /api/v1/agents/{id}/llm/responses` -### `POST /api/bots/{id}/llm/v1/responses` +### `POST /api/v1/agents/{id}/llm/v1/responses` -Forwards OpenAI-compatible Responses API requests to the LLM bridge. Codex runtime uses this entrypoint for provider traffic. If the selected upstream provider returns an unsupported Responses endpoint status, the bridge falls back to upstream chat completions and wraps the result in a Responses-compatible response for Codex. +### `GET /api/v1/agents/{id}/llm/responses` -### `GET /api/bots/{id}/llm/responses` +### `GET /api/v1/agents/{id}/llm/v1/responses` -### `GET /api/bots/{id}/llm/v1/responses` +Forwards OpenAI-compatible Responses API requests to the LLM bridge. Codex runtime uses this entrypoint for provider traffic. If the selected upstream provider returns an unsupported Responses endpoint status, the bridge falls back to upstream chat completions and wraps the result in a Responses-compatible response for Codex. -Upgrades to an OpenAI-compatible Responses WebSocket. Codex runtime enables this path only when the selected model provider is Codex, so the bridge can forward Codex ACP WebSocket traffic through the embedded CLIProxy Codex provider. +The `GET` variants are websocket upgrade endpoints for Responses API sessions. Example request body: @@ -1164,7 +1170,6 @@ Notes: - The `model` field is overwritten with the agent's resolved `model_id` - Responses forwarding does not inject the chat-only top-level `reasoning_effort` - Upstream Responses headers, status, and body are copied through, including streaming responses such as `text/event-stream` -- Responses WebSocket `response.create` payloads also have profile request options merged before they are forwarded upstream ## Compatibility Notes @@ -1179,4 +1184,10 @@ Notes: The following paths often seen in older docs are no longer registered in the current router and should not be treated as public APIs: - `/api/v1/notify/{agent_id}` +- `/api/v1/channels/{channel}/bots` +- `/api/v1/channels/{channel}/bots/{id}` +- `/api/v1/channels/feishu/bots/{id}/events` +- `/api/bots/{id}/events` +- `/api/bots/{id}/messages/send` +- `/api/bots/{id}/llm/*` - Any other legacy path not registered in `internal/api/router.go` diff --git a/docs/api.zh.md b/docs/api.zh.md index 9d95a11a..840bcfb9 100644 --- a/docs/api.zh.md +++ b/docs/api.zh.md @@ -8,27 +8,25 @@ - 时间字段使用 RFC3339 / ISO8601 - 常规错误通常返回纯文本错误正文 - SSE 接口返回 `text/event-stream` -- 当前 API 主要分为 4 组: +- 当前 API 主要分为 3 组: - 核心 API:`/api/v1/*` - - Channel API:`/api/v1/channels/*` - - Bot 兼容 API:`/api/bots/*` + - Channel 与 participant API:`/api/v1/channels/*` - 健康检查:`/healthz` ## 认证 - 默认大多数 `/api/v1/*` 接口不要求认证 - 以下接口要求 `Authorization: Bearer `,其中 token 为服务端 access token - - `GET /api/v1/channels/feishu/bots/{id}/events` - - `GET /api/bots/{id}/events` - - `POST /api/bots/{id}/messages/send` - - `GET /api/bots/{id}/llm/models` - - `GET /api/bots/{id}/llm/v1/models` - - `POST /api/bots/{id}/llm/chat/completions` - - `POST /api/bots/{id}/llm/v1/chat/completions` - - `GET /api/bots/{id}/llm/responses` - - `GET /api/bots/{id}/llm/v1/responses` - - `POST /api/bots/{id}/llm/responses` - - `POST /api/bots/{id}/llm/v1/responses` + - `GET /api/v1/channels/{channel}/participants/{id}/events` + - `POST /api/v1/channels/csgclaw/participants/{id}/messages` + - `GET /api/v1/agents/{id}/llm/models` + - `GET /api/v1/agents/{id}/llm/v1/models` + - `POST /api/v1/agents/{id}/llm/chat/completions` + - `POST /api/v1/agents/{id}/llm/v1/chat/completions` + - `POST /api/v1/agents/{id}/llm/responses` + - `POST /api/v1/agents/{id}/llm/v1/responses` + - `GET /api/v1/agents/{id}/llm/responses` + - `GET /api/v1/agents/{id}/llm/v1/responses` - 若服务端开启 `no_auth`,上述鉴权会被跳过 ## 健康检查 @@ -88,13 +86,13 @@ ok 若升级管理器未配置,返回 `503 Service Unavailable`。 -## Bot 管理 API +## Participant API -这组接口挂在 channel API 命名空间下,但底层仍由统一的 `internal/bot` 服务负责编排,当前并没有按 channel 拆成独立 bot service。`role` 仅支持 `manager` 和 `worker`,`channel` 仅支持 `csgclaw` 和 `feishu`。 +Participant 是 channel-scoped identity,用于房间、消息、mention、通知和 runtime bridge。Participant 可以表示 human、agent-backed channel identity 或 notification sender。 -### `GET /api/v1/channels/{channel}/bots` +### `GET /api/v1/channels/{channel}/participants` -获取指定 channel 下的 bot 列表。 +获取指定 channel 下的 participant 列表。 路径参数: @@ -102,29 +100,36 @@ ok 可选查询参数: -- `role` +- `type`:`human`、`agent` 或 `notification` +- `agent_id` 响应字段: - `id` -- `name` -- `description` -- `role` - `channel` +- `type` +- `name` +- `avatar` +- `channel_user_ref` +- `channel_user_kind` +- `channel_app_ref` - `agent_id` -- `user_id` -- `available` -- `runtime_kind` +- `lifecycle_status` +- `presence` +- `mentionable` +- `metadata` - `created_at` +- `updated_at` 示例: -- `GET /api/v1/channels/csgclaw/bots` -- `GET /api/v1/channels/feishu/bots?role=worker` +- `GET /api/v1/channels/csgclaw/participants` +- `GET /api/v1/channels/csgclaw/participants?type=notification` +- `GET /api/v1/channels/feishu/participants?agent_id=u-worker` -### `POST /api/v1/channels/{channel}/bots` +### `POST /api/v1/channels/{channel}/participants` -在指定 channel 下创建 bot。 +在指定 channel 下创建 participant。 路径参数: @@ -134,42 +139,58 @@ ok ```json { - "id": "u-alice", - "name": "alice", - "role": "worker", - "runtime_kind": "codex", - "from_template": "local.review-bot" + "id": "qa", + "type": "agent", + "name": "QA", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "from_template": "builtin.picoclaw-worker" + } + } } ``` 说明: +- `type` 必填,且只能是 `human`、`agent` 或 `notification` - `name` 必填 -- `role` 必填,且只能是 `manager` 或 `worker` - 实际 channel 由路由路径决定,而不是由请求体决定 -- `worker` bot 会关联后端 agent -- `manager` / `worker` 在不同 channel 上的创建行为可能不同 +- `agent` participant 可通过 `agent_binding` 创建或复用 Agent +- `human` 与 `notification` participant 不创建 runtime agent +- 对 `csgclaw` 来说,`channel_user.ref` 是本地 IM user ID +- 对 `feishu` 来说,`channel_user.ref` 是 channel-native open ID 示例: -- `POST /api/v1/channels/csgclaw/bots` -- `POST /api/v1/channels/feishu/bots` +- `POST /api/v1/channels/csgclaw/participants` +- `POST /api/v1/channels/feishu/participants` -### `DELETE /api/v1/channels/{channel}/bots/{id}` +### `GET /api/v1/channels/{channel}/participants/{id}` -删除指定 channel 下的 bot。 +获取单个 participant。 -路径参数: +### `PATCH /api/v1/channels/{channel}/participants/{id}` -- `channel`:`csgclaw` 或 `feishu` -- `id`:bot ID +更新 `name`、`avatar`、`mentionable`、`metadata` 等可编辑 participant 字段。 + +### `DELETE /api/v1/channels/{channel}/participants/{id}` + +删除指定 channel 下的 participant。 成功返回 `204 No Content`。 示例: -- `DELETE /api/v1/channels/csgclaw/bots/u-alice` -- `DELETE /api/v1/channels/feishu/bots/u-alice` +- `DELETE /api/v1/channels/csgclaw/participants/qa` +- `DELETE /api/v1/channels/feishu/participants/qa` ## Agent API @@ -533,26 +554,6 @@ ok - 缺少 `provider` 返回 `400` - 登录失败返回 `502 Bad Gateway` -### `POST /api/v1/cliproxy/auth/logout` - -禁用本地 provider 鉴权。 - -请求体: - -```json -{ - "provider": "codex" -} -``` - -成功返回 provider 当前鉴权状态。 - -说明: - -- 缺少 `provider` 返回 `400` -- Logout 失败返回 `502 Bad Gateway` -- Logout 会阻止同一个 Codex home auth 或 Claude Keychain 记录被立刻自动导入。 - ## Bootstrap Config API ### `GET /api/v1/config/bootstrap` @@ -953,17 +954,17 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 } ``` -### Bot 事件 +### Participant 事件 -#### `GET /api/v1/channels/feishu/bots/{id}/events` +#### `GET /api/v1/channels/feishu/participants/{id}/events` -订阅指定 bot 在飞书中的被提及消息事件。 +订阅指定 participant 在飞书中的被提及消息事件。 特点: - 需要 Bearer Token - 返回 `text/event-stream` -- 只转发“消息里 mention 到该 bot open_id”的事件 +- 只转发“消息里 mention 到该 participant open_id”的事件 - 建立连接后先输出 `: connected` ### 用户 @@ -1026,16 +1027,16 @@ context 不会被渲染成 thread 内消息;它是给 LLM-backed agent 使用 } ``` -## Bot 兼容 API +## Runtime Bridge API -这组接口位于 `/api/bots/{id}`,用于兼容旧的 PicoClaw Bot 接入方式。 +Runtime client 使用 participant-scoped 路由处理 channel 消息,使用 agent-scoped 路由处理 LLM provider 流量。旧的 `/api/bots/*` 路由不再注册。 Bot 和 Codex bridge 使用的 thread/session 隔离规则见 [im-threads.zh.md](./im-threads.zh.md)。 -### `GET /api/bots/{id}/events` +### `GET /api/v1/channels/{channel}/participants/{id}/events` -订阅 bot 事件流。 +订阅 participant 事件流。 特点: @@ -1055,13 +1056,13 @@ data: {"message_id":"msg-1","room_id":"room-1","channel":"csgclaw","chat_id":"ro ``` 对于 thread replies,`thread_root_id` 是 root message ID,`thread_context` -携带 thread 开启时记录的确定性隐藏上下文。Bot/LLM bridge 会把它作为 +携带 thread 开启时记录的确定性隐藏上下文。Runtime/LLM bridge 会把它作为 prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 可以把 `context.topic_id` 当作同一个 thread/session 标识。 -### `POST /api/bots/{id}/messages/send` +### `POST /api/v1/channels/csgclaw/participants/{id}/messages` -向 bot 兼容通道发送消息。 +以指定本地 CSGClaw participant 身份发送消息。 请求体示例: @@ -1074,8 +1075,8 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client ``` `thread_root_id`、`topic_id` 和 `context.topic_id` 都是可选的 thread/topic -标识;传入任一字段时 bot 响应会发送到该 IM thread 中。全部省略时, -响应会作为 room/DM 顶层消息发送;服务端不会根据 bot 在房间中最近收到的 +标识;传入任一字段时 participant 响应会发送到该 IM thread 中。全部省略时, +响应会作为 room/DM 顶层消息发送;服务端不会根据 participant 在房间中最近收到的 事件推断 thread。 也接受 PicoClaw outbound message 形态: @@ -1092,9 +1093,9 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client } ``` -### `GET /api/bots/{id}/llm/models` +### `GET /api/v1/agents/{id}/llm/models` -### `GET /api/bots/{id}/llm/v1/models` +### `GET /api/v1/agents/{id}/llm/v1/models` 转发模型列表请求到 LLM bridge。 @@ -1103,9 +1104,9 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client - 需要 Bearer Token - 返回内容类型和响应体由上游 bridge 决定 -### `POST /api/bots/{id}/llm/chat/completions` +### `POST /api/v1/agents/{id}/llm/chat/completions` -### `POST /api/bots/{id}/llm/v1/chat/completions` +### `POST /api/v1/agents/{id}/llm/v1/chat/completions` 转发聊天补全请求到 LLM bridge。 @@ -1126,17 +1127,17 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client } ``` -### `POST /api/bots/{id}/llm/responses` +### `POST /api/v1/agents/{id}/llm/responses` -### `POST /api/bots/{id}/llm/v1/responses` +### `POST /api/v1/agents/{id}/llm/v1/responses` -转发 OpenAI-compatible Responses API 请求到 LLM bridge。Codex runtime 使用这个入口发送 provider 流量。如果所选上游 provider 返回不支持 Responses endpoint 的状态,bridge 会回退到上游 chat completions,并把结果包装成 Codex 可消费的 Responses-compatible response。 +### `GET /api/v1/agents/{id}/llm/responses` -### `GET /api/bots/{id}/llm/responses` +### `GET /api/v1/agents/{id}/llm/v1/responses` -### `GET /api/bots/{id}/llm/v1/responses` +转发 OpenAI-compatible Responses API 请求到 LLM bridge。Codex runtime 使用这个入口发送 provider 流量。如果所选上游 provider 返回不支持 Responses endpoint 的状态,bridge 会回退到上游 chat completions,并把结果包装成 Codex 可消费的 Responses-compatible response。 -升级为 OpenAI-compatible Responses WebSocket。只有当选择的模型 provider 是 Codex 时,Codex runtime 才会启用这条路径,让 bridge 可以把 Codex ACP 的 WebSocket 流量转发到内置 CLIProxy 的 Codex provider。 +`GET` 形式是 Responses API session 的 websocket upgrade 入口。 请求体示例: @@ -1156,7 +1157,6 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client - `model` 字段会被覆盖为 agent 已解析出的 `model_id` - Responses 转发不会注入 chat-only 的顶层 `reasoning_effort` - 上游 Responses 的 headers、status 和 body 会被透传,包括 `text/event-stream` 这类流式响应 -- Responses WebSocket 的 `response.create` payload 同样会在转发前合并 profile request options ## 兼容性说明 @@ -1171,4 +1171,10 @@ prompt context 使用;它不是 thread reply 列表。PicoClaw 原生 client 以下旧文档中常见路径,当前路由里已不存在,不应再作为对外 API 使用: - `/api/v1/notify/{agent_id}` +- `/api/v1/channels/{channel}/bots` +- `/api/v1/channels/{channel}/bots/{id}` +- `/api/v1/channels/feishu/bots/{id}/events` +- `/api/bots/{id}/events` +- `/api/bots/{id}/messages/send` +- `/api/bots/{id}/llm/*` - 任何未在 `internal/api/router.go` 中注册的旧路径 diff --git a/docs/architecture.md b/docs/architecture.md index 1d8a92ca..f7eb40e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -30,13 +30,13 @@ The following diagram shows the relationships among the main CSGClaw concepts. | dependency v +----------------------------------------------------------------------------------------+ -| Bot | +| Participant | | | | +--------------------------+ +----------------------------+ +--------------------+ | -| | Normal Bot | | Notification Bot | | A2A Bot (planned) | | +| | Agent Participant | | Notification Participant | | Human Participant | | | | | | | | | | -| | User <-------> Agent | | User <-------> Pull/Push | | User <----> A2A | | -| | | | | Notification | | Agent | | +| | User <-------> Agent | | User <-------> Pull/Push | | User identity | | +| | | | | Notification | | | | | +-----------------|--------+ +----------------------------+ +--------------------+ | +--------------------|-------------------------------------------------------------------+ | @@ -67,41 +67,42 @@ The following diagram shows the relationships among the main CSGClaw concepts. -CSGClaw is a Go-based local multi-agent platform. It runs a single local HTTP server, serves the Web UI, exposes REST/SSE/WebSocket APIs, and manages channels, rooms, bots, runtimes, sandboxes, users, and messages. +CSGClaw is a Go-based local multi-agent platform. It runs a single local HTTP server, serves the Web UI, exposes REST/SSE/WebSocket APIs, and manages channels, rooms, participants, agents, runtimes, sandboxes, users, and messages. The ASCII diagram describes the system as five layers: - **Channel**: the external or built-in interaction surface, such as `csgclaw` IM, Feishu / Lark, or a planned Matrix integration. -- **Room**: the collaboration container controlled by a channel. Each room typically contains one `manager` bot and multiple `worker` bots. -- **Bot**: the product-facing identity inside a room. Current bot shapes are a normal bot and a notification bot, with A2A bot support planned. -- **Runtime**: the executable agent runtime behind a bot, such as PicoClaw Sandbox, OpenClaw Sandbox, or Codex. +- **Room**: the collaboration container controlled by a channel. Each room can contain humans, agent participants, and notification participants. +- **Participant**: the product-facing channel identity inside a room. Participant types are `human`, `agent`, and `notification`. +- **Agent**: the runtime-managed execution identity optionally bound to an `agent` participant. +- **Runtime**: the executable agent runtime, such as PicoClaw Sandbox, OpenClaw Sandbox, or Codex. - **Sandbox**: the isolation backend used by a runtime, such as BoxLite, Docker, or CSGHub. The dependency direction in the diagram is intentional: ```text -channel -> room -> bot -> runtime -> sandbox +channel -> room -> participant -> agent -> runtime -> sandbox ``` -Each upper layer orchestrates the layer below it. A channel controls rooms, a room coordinates manager and worker bots, a bot delegates execution to a runtime, and the runtime relies on a sandbox provider for isolation. +Each upper layer orchestrates the layer below it. A channel controls rooms, a room contains participants, an agent participant may bind to an Agent, the Agent delegates execution to a runtime, and the runtime relies on a sandbox provider for isolation. -Within that model, a bot remains the stable binding object exposed to users: +Within that model, a participant is the stable binding object exposed to users: ```text -bot - ├─ role: manager | worker - ├─ room_id ───────────► collaboration context in a channel room - ├─ agent_id ───────────► runtime instance - └─ channel + user_id ───► user identity in the selected channel +participant + ├─ type: human | agent | notification + ├─ channel + participant_id ─► stable channel identity + ├─ channel_user_ref ─────────► user identity in the selected channel + └─ agent_id ─────────────────► optional runtime Agent ``` -This keeps channel messaging in `internal/im` and `internal/channel`, room-level collaboration in the room and message services, bot lifecycle logic in `internal/bot`, runtime execution in `internal/runtime` / `internal/agent`, and sandbox integration behind the runtime and sandbox packages. +This keeps channel messaging in `internal/im` and `internal/channel`, room-level collaboration in the room and message services, participant provisioning in `internal/participant`, runtime execution in `internal/runtime` / `internal/agent`, and sandbox integration behind the runtime and sandbox packages. In the current codebase, those layers map roughly as follows: - **Channel layer**: implemented by the built-in `internal/im` services and external adapters under `internal/channel/*`. - **Room layer**: represented by room, membership, message, and thread flows exposed through the IM and channel APIs. -- **Bot layer**: implemented by `internal/bot`, including normal bot and notification bot lifecycle. +- **Participant layer**: implemented by `internal/participant`, including human, agent, and notification participants. - **Runtime layer**: implemented primarily by `internal/runtime/*` and `internal/agent`. - **Sandbox layer**: implemented by sandbox backends such as `internal/sandbox/boxlitecli`, plus runtime-specific sandbox integration paths. @@ -114,7 +115,7 @@ The local HTTP server and Web UI sit beside these layers as operator and user en - `cmd/csgclaw` and `cmd/csgclaw-cli` stay thin. They should only start their CLI entrypoints. - `cli` owns command parsing, HTTP calls, and output formatting. - `internal/api` owns HTTP request/response handling only. -- `internal/bot` owns bot creation and listing. It coordinates `agent` and channel user creation. +- `internal/participant` owns participant creation and listing. It coordinates `agent` and channel user creation when needed. - `internal/agent` owns agent lifecycle and logs through `internal/sandbox`. - `internal/im` owns the built-in `csgclaw` IM. - `internal/channel` owns external channel integrations such as Feishu. @@ -129,8 +130,8 @@ Threads are root-message-anchored sub-conversations inside a room or DM. They use Matrix-shaped `m.thread` relation metadata while staying inside the existing CSGClaw IM API surface. -Thread replies are hidden from the main room timeline by default. Bot and Codex -runtime bridges scope normal conversations by `room_id` and thread conversations +Thread replies are hidden from the main room timeline by default. Runtime and Codex +bridges scope normal conversations by `room_id` and thread conversations by `room_id:thread_root_id`, so each thread starts with clean runtime context plus the hidden root context snapshot. @@ -146,7 +147,7 @@ cli/csgclawcli/ csgclaw-cli app wiring and global flag handling cli/message/ shared message command implementation for csgclaw and csgclaw-cli internal/server/ local HTTP server and static UI wiring internal/api/ HTTP handlers and route registration -internal/bot/ bot lifecycle and agent/user binding +internal/participant/ participant lifecycle and optional agent/user binding internal/agent/ agent runtime and storage internal/sandbox/ runtime-neutral sandbox interfaces internal/sandbox/boxlitecli/ BoxLite CLI sandbox implementation @@ -158,35 +159,34 @@ web/app/ Web UI development source and Vite project web/static-dist/ generated Web UI assets for Go embed; run make build-web ``` -`internal/bot` is the new business boundary for bot behavior. It should not be implemented as extra glue inside API handlers. +`internal/participant` is the business boundary for participant behavior. It should not be implemented as extra glue inside API handlers. --- -## Bot Model +## Participant Model -The bot record is the stable object exposed to users and higher-level workflows. +The participant record is the stable channel identity exposed to users and higher-level workflows. Typical fields: ```json { - "id": "bot-alice", - "name": "alice", - "role": "worker", + "id": "alice", "channel": "csgclaw", - "agent_id": "agent-alice", - "user_id": "u-alice" + "type": "agent", + "name": "Alice", + "channel_user_ref": "u-alice", + "channel_user_kind": "local_user_id", + "agent_id": "u-alice" } ``` -Rules: +Legacy notes: -- `role` must be `manager` or `worker`. -- `channel` defaults to `csgclaw`. -- `channel` may be `csgclaw` or `feishu`. -- each bot maps to exactly one agent. -- each bot maps to exactly one user in the selected channel. -- bot creation should create or bind both underlying identities, then persist the bot mapping. +- Product-facing collaboration identities are participants, not bots. +- A participant is scoped to a channel and has `type=human|agent|notification`. +- `agent` participants may create or bind a runtime Agent. +- Channel user identity belongs to participant state, while runtime state belongs to Agent. --- @@ -195,9 +195,12 @@ Rules: All new product APIs should live under `/api/v1`. ```text -# Bot -GET /api/v1/channels/{channel}/bots List bots -POST /api/v1/channels/{channel}/bots Create a bot +# Participant +GET /api/v1/channels/{channel}/participants List participants +POST /api/v1/channels/{channel}/participants Create a participant +GET /api/v1/channels/{channel}/participants/{id} Get a participant +PATCH /api/v1/channels/{channel}/participants/{id} Update a participant +DELETE /api/v1/channels/{channel}/participants/{id} Delete a participant # Agent GET /api/v1/agents List agents @@ -229,17 +232,17 @@ POST /api/v1/channels/feishu/rooms/{room_id}/members POST /api/v1/channels/feishu/messages ``` -`POST /api/v1/channels/{channel}/bots` should be handled as a bot use case: +`POST /api/v1/channels/{channel}/participants` should be handled as a participant provisioning use case: ```text API handler - └─► internal/bot.Create - ├─► create or bind agent through internal/agent + └─► internal/participant.Create + ├─► create or bind Agent through internal/agent when type=agent ├─► create or bind channel user through internal/im or internal/channel - └─► persist bot mapping + └─► persist participant identity ``` -The API layer should not directly duplicate bot orchestration logic. +The API layer should not directly duplicate participant provisioning logic. --- @@ -247,14 +250,14 @@ The API layer should not directly duplicate bot orchestration logic. Both CLIs are thin HTTP clients. They should not call stores, BoxLite, or channel SDKs directly. -`csgclaw` is the full local management CLI for human operators. It owns server lifecycle, agent runtime commands, and the shared bot/room/member/user/message workflows. +`csgclaw` is the full local management CLI for human operators. It owns server lifecycle, agent runtime commands, and the shared participant/room/member/user/message workflows. -`csgclaw-cli` is the lightweight CLI primarily intended for agents and scripts. It exposes only the bot, room, member, and message workflows that agents need for collaboration, and does not manage the local server lifecycle or agent runtime directly. +`csgclaw-cli` is the lightweight CLI primarily intended for agents and scripts. It exposes only the participant, room, member, and message workflows that agents need for collaboration, and does not manage the local server lifecycle or agent runtime directly. At a high level: - `csgclaw` includes local operator workflows such as `serve`, `stop`, and agent management, plus shared collaboration commands. -- `csgclaw-cli` keeps only the collaboration-oriented command groups needed by bots, agents, and scripts. +- `csgclaw-cli` keeps only the collaboration-oriented command groups needed by participants, agents, and scripts. - Shared collaboration commands select the target channel through flags and call the same local HTTP API surface. For the current command tree, flags, defaults, and examples, see [cli.md](./cli.md) or [cli.zh.md](./cli.zh.md). @@ -264,17 +267,17 @@ For the current command tree, flags, defaults, and examples, see [cli.md](./cli. ## Creation Flow ```text -csgclaw bot create --channel feishu - └─► POST /api/v1/channels/feishu/bots - └─► internal/bot.Create - ├─► internal/agent creates BoxLite-backed agent - ├─► internal/channel creates Feishu user - └─► internal/bot saves: - bot_id - role +csgclaw participant create --channel feishu --type agent + └─► POST /api/v1/channels/feishu/participants + └─► internal/participant.Create + ├─► internal/agent creates or reuses runtime Agent + ├─► internal/channel binds Feishu channel identity + └─► internal/participant saves: + participant_id + type channel agent_id - user_id + channel_user_ref ``` For the built-in channel, the same flow uses `internal/im` to create the user identity. @@ -288,16 +291,16 @@ Filesystem storage remains the default persistence layer. Each domain owns its own records: - `agent`: runtime metadata and sandbox state references -- `bot`: bot-to-agent-to-channel-user mapping +- `participant`: channel identity and optional agent binding - `im`: built-in rooms, users, messages, and events - `channel`: external channel integration state when needed -Do not store channel-specific details directly inside the agent record. The agent should remain the runtime object; channel identity belongs to bot/channel state. +Do not store channel-specific details directly inside the agent record. The agent should remain the runtime object; channel identity belongs to participant/channel state. --- ## Notes -- Existing compatibility routes, such as PicoClaw-specific bot APIs or older IM aliases, can remain for compatibility, but new bot lifecycle work should use `/api/v1/channels/{channel}/bots`. -- Feishu support should live behind `internal/channel`, while bot lifecycle decisions stay in `internal/bot`. -- When changing config fields or defaults for bot/channel behavior, update loader, saver, bootstrap initialization flow, tests, and docs together. +- Legacy bot compatibility routes are removed from the target API. Runtime clients should use participant-scoped event/message routes and agent-scoped LLM routes. +- Feishu support should live behind `internal/channel`, while participant provisioning decisions stay in `internal/participant`. +- When changing config fields or defaults for participant/channel behavior, update loader, saver, bootstrap initialization flow, tests, and docs together. diff --git a/docs/channel/csgclaw.md b/docs/channel/csgclaw.md index cfa47b16..efee3f87 100644 --- a/docs/channel/csgclaw.md +++ b/docs/channel/csgclaw.md @@ -54,15 +54,15 @@ Do not call `POST /api/v1/agents/u-manager/recreate` for this flow. - Never return or log secret values (for example `app_secret`, API keys, tokens). - If any sensitive value appears in logs, use masked forms such as `present`. -## Notification bots +## Notification participants -Notification bots are channel bots with `type=notification`. They do not create backing worker agents; delivery configuration is stored on `bot.runtime_options` in `bots.json`. Default bot id is `n-{name}` (separate from worker agent ids `u-{name}`); you may set `id` explicitly, but it must not collide with an existing agent or channel bot. +Notification senders are CSGClaw participants with `type=notification`. They do not create backing worker agents; delivery configuration is stored in participant `metadata`. Default participant id is `n-{name}` (separate from worker agent ids `u-{name}`); you may set `id` explicitly, but it must not collide with an existing participant in the channel. -- List: `GET /api/v1/channels/csgclaw/bots` (includes `type=notification` bots; feishu bot list excludes them) -- Create: `POST /api/v1/channels/csgclaw/bots` with `"type":"notification"` and flat `runtime_options` (`delivery_mode`, `webhook_token`, `remote_url`, …) -- Update: `PATCH /api/v1/channels/csgclaw/bots/{id}` -- Delete: `DELETE /api/v1/channels/csgclaw/bots/{id}` -- Push (webhook): `POST /api/v1/channels/csgclaw/bots/{id}/notifications` with `Authorization: Bearer ` +- List: `GET /api/v1/channels/csgclaw/participants?type=notification` +- Create: `POST /api/v1/channels/csgclaw/participants` with `"type":"notification"` and `metadata` (`delivery_mode`, `webhook_token`, `remote_url`, ...) +- Update: `PATCH /api/v1/channels/csgclaw/participants/{id}` +- Delete: `DELETE /api/v1/channels/csgclaw/participants/{id}` +- Push (webhook): `POST /api/v1/channels/csgclaw/participants/{id}/notifications` with `Authorization: Bearer ` Implementation: `internal/channel/csgclaw/notification_bot/`, `internal/bot/notification.go`. diff --git a/docs/channel/csgclaw.zh.md b/docs/channel/csgclaw.zh.md index 4e1d678b..f744136a 100644 --- a/docs/channel/csgclaw.zh.md +++ b/docs/channel/csgclaw.zh.md @@ -54,15 +54,15 @@ Content-Type: application/json - 不要返回或记录敏感凭证(如 `app_secret`、API key、token)。 - 若敏感值出现在日志中,应使用掩码形式(例如 `present`)。 -## Notification bot(通知机器人) +## Notification participant(通知参与者) -通知机器人是 `type=notification` 的 channel bot,不创建 backing worker agent;投递配置保存在 `bots.json` 的 `bot.runtime_options` 中。默认 bot id 为 `n-{name}`(与 worker agent 的 `u-{name}` 区分);创建时也可显式指定 `id`,但不得与已有 agent 或其它 channel bot 冲突。 +通知发送者是 `type=notification` 的 CSGClaw participant,不创建 backing worker agent;投递配置保存在 participant `metadata` 中。默认 participant id 为 `n-{name}`(与 worker agent 的 `u-{name}` 区分);创建时也可显式指定 `id`,但不得与同 channel 下已有 participant 冲突。 -- 列表:`GET /api/v1/channels/csgclaw/bots`(含 `type=notification`;feishu channel 列表不包含通知 bot) -- 创建:`POST /api/v1/channels/csgclaw/bots`,请求体含 `"type":"notification"` 与扁平 `runtime_options` -- 更新:`PATCH /api/v1/channels/csgclaw/bots/{id}` -- 删除:`DELETE /api/v1/channels/csgclaw/bots/{id}` -- 推送(webhook):`POST /api/v1/channels/csgclaw/bots/{id}/notifications`,请求头 `Authorization: Bearer ` +- 列表:`GET /api/v1/channels/csgclaw/participants?type=notification` +- 创建:`POST /api/v1/channels/csgclaw/participants`,请求体含 `"type":"notification"` 与 `metadata` +- 更新:`PATCH /api/v1/channels/csgclaw/participants/{id}` +- 删除:`DELETE /api/v1/channels/csgclaw/participants/{id}` +- 推送(webhook):`POST /api/v1/channels/csgclaw/participants/{id}/notifications`,请求头 `Authorization: Bearer ` 实现:`internal/channel/csgclaw/notification_bot/`、`internal/bot/notification.go`。 diff --git a/docs/cli.md b/docs/cli.md index 07c2da1e..3a51d3c8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,7 +6,7 @@ This document supplements the CLI section in [architecture.md](./architecture.md `csgclaw` is the full local operator CLI. It manages local server lifecycle, agent runtime operations, and the shared collaboration workflows. -`csgclaw-cli` is the lightweight HTTP client intended for bots, agents, and scripts. It exposes only collaboration-oriented workflows and does not manage config files or server lifecycle. +`csgclaw-cli` is the lightweight HTTP client intended for participants, agents, and scripts. It exposes only collaboration-oriented workflows and does not manage config files or server lifecycle. Both CLIs are thin HTTP clients over the local API. They do not talk to BoxLite, stores, or channel SDKs directly. @@ -80,8 +80,9 @@ Top-level commands: - `upgrade` - `agent` - `model` +- `participant` +- `pt` - `user` -- `bot` - `room` - `member` - `message` @@ -439,12 +440,13 @@ csgclaw user delete u-alice The following command groups are shared with `csgclaw-cli` and use the same flags and semantics. -#### `bot` +#### `participant` Usage: ```bash -csgclaw bot [flags] +csgclaw participant [flags] +csgclaw pt [flags] ``` Subcommands: @@ -453,27 +455,39 @@ Subcommands: - `create` - `delete` -`bot list` flags: +`participant list` flags: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. -- `--role string`: filter by `manager` or `worker`. +- `--type string`: filter by `human`, `agent`, or `notification`. +- `--agent-id string`: filter by bound agent ID. -`bot create` flags: +`participant create` flags: -- `--id string`: bot ID. -- `--name string`: required. -- `--description string`: bot description. -- `--role string`: required. `manager` or `worker`. - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. -- `--model-id string`: agent model ID. +- `--id string`: participant ID. +- `--name string`: required participant display name. +- `--description string`: participant metadata description and agent description for `--bind create`. +- `--type string`: `human`, `agent`, or `notification`. Default `agent`. +- `--channel-user-ref string`: channel user identity, such as a local user ID or Feishu open_id. +- `--channel-user-kind string`: channel user identity kind, such as `local_user_id` or `open_id`. +- `--channel-app-ref string`: channel app/config reference, such as a Feishu app_id. +- `--bind string`: agent binding mode: `create`, `reuse`, or `none`. Default `none`. +- `--agent-id string`: agent ID for `--bind reuse`, or optional agent ID for `--bind create`. +- `--role string`: agent role for `--bind create`. +- `--runtime string`: agent runtime kind for `--bind create`. +- `--image string`: agent image for `--bind create`. +- `--from-template string`: hub template for `--bind create`. +- `--model-id string`: agent model ID for `--bind create`. +- `--env KEY=VALUE`: agent image environment variable for `--bind create`; repeatable. -`bot delete` usage and flags: +`participant delete` usage and flags: ```bash -csgclaw bot delete [flags] +csgclaw participant delete [flags] ``` - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. +- `--delete-agent string`: agent cleanup mode. Supported value: `if_unreferenced`. #### `room` @@ -498,11 +512,11 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--title string`: room title. - `--description string`: room description. -- `--creator-id string`: creator bot ID, such as `u-manager`. -- `--member-ids string`: comma-separated bot IDs, such as `u-manager,u-dev`. +- `--creator-id string`: creator participant ID, such as `manager`. +- `--member-ids string`: comma-separated participant IDs, such as `manager,u-dev`. - `--locale string`: room locale. -Design note for `csgclaw-cli`: room creation should expose CSGClaw bot IDs, not channel user IDs, agent IDs, Feishu open IDs, Feishu app IDs, or app credentials. In the Feishu channel, the channel adapter resolves bot IDs to the configured Feishu app credentials and bot identifiers internally. When Feishu group creation needs a real human owner ID, CSGClaw continues to use the configured `admin_open_id` internally; callers should still pass bot IDs at the CLI boundary. +Design note for `csgclaw-cli`: room creation should expose CSGClaw participant IDs, not channel user IDs, agent IDs, Feishu open IDs, Feishu app IDs, or app credentials. In the Feishu channel, the channel adapter resolves participant IDs to the configured Feishu app credentials and channel identifiers internally. When Feishu group creation needs a real human owner ID, CSGClaw continues to use the configured `admin_open_id` internally; callers should still pass participant IDs at the CLI boundary. `room delete` usage and flags: @@ -534,14 +548,14 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--room-id string`: target room ID. -- `--user-id string`: required. Bot ID to add, such as `u-dev`. -- `--inviter-id string`: inviter bot ID, such as `u-manager`. +- `--user-id string`: required. Participant ID to add, such as `u-dev`. +- `--inviter-id string`: inviter participant ID, such as `manager`. - `--locale string`: room locale. `member create` behavior: - `--user-id` is required. -- `csgclaw-cli` room membership commands should use bot IDs consistently across channels. Feishu open IDs and app IDs are channel implementation details. +- `csgclaw-cli` room membership commands should use participant IDs consistently across channels. Feishu open IDs and app IDs are channel implementation details. #### `message` @@ -565,9 +579,9 @@ Subcommands: - `--channel string`: `csgclaw` or `feishu`. Default `csgclaw`. - `--room-id string`: required. -- `--sender-id string`: required sender bot ID. +- `--sender-id string`: required sender participant ID. - `--content string`: required. -- `--mention-id string`: optional mentioned bot ID. +- `--mention-id string`: optional mentioned participant ID. `message list` behavior: @@ -576,12 +590,12 @@ Subcommands: Examples: ```bash -csgclaw bot list -csgclaw bot create --name alice --role worker --model-id gpt-5.4-mini -csgclaw room create --title "release-room" --creator-id u-manager --member-ids u-manager,u-alice -csgclaw member create --room-id room-1 --user-id u-alice --inviter-id u-manager +csgclaw participant list +csgclaw participant create --name alice --bind create --role worker --model-id gpt-5.4-mini +csgclaw room create --title "release-room" --creator-id manager --member-ids manager,u-alice +csgclaw member create --room-id room-1 --user-id u-alice --inviter-id manager csgclaw message list --room-id room-1 -csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-manager --content hello +csgclaw message create --channel csgclaw --room-id room-1 --sender-id manager --content hello ``` ## `csgclaw-cli` @@ -603,7 +617,8 @@ Global flags: Top-level commands: -- `bot` +- `participant` +- `pt` - `room` - `member` - `message` @@ -623,9 +638,12 @@ csgclaw-cli completion fish `csgclaw-cli` reuses the same implementations as `csgclaw` for: -- `bot list` -- `bot create` -- `bot delete` +- `participant list` +- `participant create` +- `participant delete` +- `pt list` +- `pt create` +- `pt delete` - `room list` - `room create` - `room delete` @@ -639,12 +657,12 @@ That means flags, defaults, validations, and JSON shapes are identical between t Examples: ```bash -csgclaw-cli bot list --channel feishu -csgclaw-cli bot create --name manager --role manager --channel feishu +csgclaw-cli participant list --channel feishu --type agent +csgclaw-cli pt create --name manager --channel feishu --type agent --bind create --role manager csgclaw-cli room create --channel feishu --title "ops-room" --creator-id u-manager --member-ids u-manager,u-dev csgclaw-cli member list --channel feishu --room-id oc_x csgclaw-cli member create --channel feishu --room-id oc_x --user-id u-dev --inviter-id u-manager csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --mention-id u-dev --content hello ``` -`csgclaw-cli` is the bot-facing CLI. It should not require callers to know or pass agent IDs, Feishu open IDs, Feishu app IDs, App ID/App Secret, or other channel credentials in room, member, or message commands. Channel-specific adapters are responsible for exchanging bot IDs for the identifiers required by the target channel. +`csgclaw-cli` is the participant-facing CLI. Room, member, and message commands should not require callers to know or pass agent IDs, Feishu open IDs, Feishu app IDs, App ID/App Secret, or other channel credentials. Channel-specific adapters are responsible for exchanging participant IDs for the identifiers required by the target channel. diff --git a/docs/cli.zh.md b/docs/cli.zh.md index 79c294cd..14a9ee14 100644 --- a/docs/cli.zh.md +++ b/docs/cli.zh.md @@ -6,7 +6,7 @@ `csgclaw` 是完整的本地运维 CLI,用于管理初始化、本地服务生命周期、Agent 运行时,以及共享的协作命令。 -`csgclaw-cli` 是轻量级 HTTP 客户端,主要面向 Bot、Agent 和脚本。它只暴露协作相关命令,不负责初始化、配置文件管理或本地服务生命周期。 +`csgclaw-cli` 是轻量级 HTTP 客户端,主要面向 participant、Agent 和脚本。它只暴露协作相关命令,不负责初始化、配置文件管理或本地服务生命周期。 两个 CLI 都是本地 API 的薄客户端,不会直接操作 BoxLite、底层存储或渠道 SDK。 @@ -80,8 +80,9 @@ csgclaw [global-flags] [args] - `upgrade` - `agent` - `model` +- `participant` +- `pt` - `user` -- `bot` - `room` - `member` - `message` @@ -439,12 +440,13 @@ csgclaw user delete u-alice 以下命令组与 `csgclaw-cli` 共享同一套实现,因此参数和行为完全一致。 -#### `bot` +#### `participant` 用法: ```bash -csgclaw bot [flags] +csgclaw participant [flags] +csgclaw pt [flags] ``` 子命令: @@ -453,27 +455,39 @@ csgclaw bot [flags] - `create` - `delete` -`bot list` 参数: +`participant list` 参数: - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 -- `--role string`:按 `manager` 或 `worker` 过滤。 +- `--type string`:按 `human`、`agent` 或 `notification` 过滤。 +- `--agent-id string`:按绑定的 Agent ID 过滤。 -`bot create` 参数: +`participant create` 参数: -- `--id string`:Bot ID。 -- `--name string`:必填。 -- `--description string`:Bot 描述。 -- `--role string`:必填,取值为 `manager` 或 `worker`。 - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 -- `--model-id string`:Agent model ID。 +- `--id string`:participant ID。 +- `--name string`:必填,participant 显示名。 +- `--description string`:participant metadata 描述;`--bind create` 时也会作为 Agent 描述。 +- `--type string`:`human`、`agent` 或 `notification`,默认 `agent`。 +- `--channel-user-ref string`:渠道用户身份,例如本地 user ID 或飞书 open_id。 +- `--channel-user-kind string`:渠道用户身份类型,例如 `local_user_id` 或 `open_id`。 +- `--channel-app-ref string`:渠道 app/config 引用,例如飞书 app_id。 +- `--bind string`:Agent 绑定模式:`create`、`reuse` 或 `none`,默认 `none`。 +- `--agent-id string`:`--bind reuse` 时的 Agent ID;`--bind create` 时也可指定要创建的 Agent ID。 +- `--role string`:`--bind create` 时的 Agent role。 +- `--runtime string`:`--bind create` 时的 Agent runtime kind。 +- `--image string`:`--bind create` 时的 Agent image。 +- `--from-template string`:`--bind create` 时使用的 hub template。 +- `--model-id string`:`--bind create` 时的 Agent model ID。 +- `--env KEY=VALUE`:`--bind create` 时的 Agent image 环境变量,可重复传入。 -`bot delete` 用法与参数: +`participant delete` 用法与参数: ```bash -csgclaw bot delete [flags] +csgclaw participant delete [flags] ``` - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 +- `--delete-agent string`:Agent 清理模式,支持 `if_unreferenced`。 #### `room` @@ -498,11 +512,11 @@ csgclaw room [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--title string`:房间标题。 - `--description string`:房间描述。 -- `--creator-id string`:创建者 bot ID,例如 `u-manager`。 -- `--member-ids string`:逗号分隔的 bot ID 列表,例如 `u-manager,u-dev`。 +- `--creator-id string`:创建者 participant ID,例如 `manager`。 +- `--member-ids string`:逗号分隔的 participant ID 列表,例如 `manager,u-dev`。 - `--locale string`:房间 locale。 -`csgclaw-cli` 设计约束:创建 room 时只暴露 CSGClaw bot ID,不暴露 channel user ID、agent ID、飞书 open_id、飞书 app_id 或应用凭证。Feishu 渠道由 adapter 在内部把 bot ID 兑换为已配置的飞书应用凭证和 bot 标识。飞书建群需要真人 owner ID 时,代码仍使用配置里的 `admin_open_id`,CLI 调用方仍只传 bot ID。 +`csgclaw-cli` 设计约束:创建 room 时只暴露 CSGClaw participant ID,不暴露 channel user ID、agent ID、飞书 open_id、飞书 app_id 或应用凭证。Feishu 渠道由 adapter 在内部把 participant ID 兑换为已配置的飞书应用凭证和渠道标识。飞书建群需要真人 owner ID 时,代码仍使用配置里的 `admin_open_id`,CLI 调用方仍只传 participant ID。 `room delete` 用法与参数: @@ -534,14 +548,14 @@ csgclaw member [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--room-id string`:目标房间 ID。 -- `--user-id string`:必填,要加入房间的 bot ID,例如 `u-dev`。 -- `--inviter-id string`:邀请人 bot ID,例如 `u-manager`。 +- `--user-id string`:必填,要加入房间的 participant ID,例如 `u-dev`。 +- `--inviter-id string`:邀请人 participant ID,例如 `manager`。 - `--locale string`:房间 locale。 `member create` 行为说明: - `--user-id` 为必填。 -- `csgclaw-cli` 的成员操作在所有渠道下都应使用 bot ID。飞书 open_id 和 app_id 是渠道内部实现细节。 +- `csgclaw-cli` 的成员操作在所有渠道下都应使用 participant ID。飞书 open_id 和 app_id 是渠道内部实现细节。 #### `message` @@ -565,9 +579,9 @@ csgclaw message [flags] - `--channel string`:`csgclaw` 或 `feishu`,默认 `csgclaw`。 - `--room-id string`:必填。 -- `--sender-id string`:必填,发送方 bot ID。 +- `--sender-id string`:必填,发送方 participant ID。 - `--content string`:必填。 -- `--mention-id string`:可选,被提及 bot ID。 +- `--mention-id string`:可选,被提及 participant ID。 `message list` 行为说明: @@ -576,12 +590,12 @@ csgclaw message [flags] 示例: ```bash -csgclaw bot list -csgclaw bot create --name alice --role worker --model-id gpt-5.4-mini -csgclaw room create --title "release-room" --creator-id u-manager --member-ids u-manager,u-alice -csgclaw member create --room-id room-1 --user-id u-alice --inviter-id u-manager +csgclaw participant list +csgclaw participant create --name alice --bind create --role worker --model-id gpt-5.4-mini +csgclaw room create --title "release-room" --creator-id manager --member-ids manager,u-alice +csgclaw member create --room-id room-1 --user-id u-alice --inviter-id manager csgclaw message list --room-id room-1 -csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-manager --content hello +csgclaw message create --channel csgclaw --room-id room-1 --sender-id manager --content hello ``` ## `csgclaw-cli` @@ -603,7 +617,8 @@ csgclaw-cli [global-flags] [args] 顶层命令: -- `bot` +- `participant` +- `pt` - `room` - `member` - `message` @@ -623,9 +638,12 @@ csgclaw-cli completion fish `csgclaw-cli` 与 `csgclaw` 复用完全相同的实现,包含: -- `bot list` -- `bot create` -- `bot delete` +- `participant list` +- `participant create` +- `participant delete` +- `pt list` +- `pt create` +- `pt delete` - `room list` - `room create` - `room delete` @@ -639,12 +657,12 @@ csgclaw-cli completion fish 示例: ```bash -csgclaw-cli bot list --channel feishu -csgclaw-cli bot create --name manager --role manager --channel feishu +csgclaw-cli participant list --channel feishu --type agent +csgclaw-cli pt create --name manager --channel feishu --type agent --bind create --role manager csgclaw-cli room create --channel feishu --title "ops-room" --creator-id u-manager --member-ids u-manager,u-dev csgclaw-cli member list --channel feishu --room-id oc_x csgclaw-cli member create --channel feishu --room-id oc_x --user-id u-dev --inviter-id u-manager csgclaw-cli message create --channel feishu --room-id oc_x --sender-id u-manager --mention-id u-dev --content hello ``` -`csgclaw-cli` 是面向 bot 的 CLI。room、member、message 命令不应要求调用方理解或传入 agent ID、飞书 open_id、飞书 app_id、App ID/App Secret 或其他渠道凭证。各 channel adapter 负责把 bot ID 转换成目标渠道需要的标识。 +`csgclaw-cli` 是面向 participant 的 CLI。room、member、message 命令不应要求调用方理解或传入 agent ID、飞书 open_id、飞书 app_id、App ID/App Secret 或其他渠道凭证。各 channel adapter 负责把 participant ID 转换成目标渠道需要的标识。 diff --git a/docs/im-threads.md b/docs/im-threads.md index 46a9a20d..1e94a588 100644 --- a/docs/im-threads.md +++ b/docs/im-threads.md @@ -1,7 +1,7 @@ # CSGClaw IM Threads This document describes the CSGClaw local IM thread model. It is meant for -maintainers who need to understand how thread storage, APIs, bot compatibility, +maintainers who need to understand how thread storage, APIs, participant bridges, agent context, and UI behavior fit together. ## Summary @@ -9,7 +9,7 @@ agent context, and UI behavior fit together. CSGClaw uses an incremental Matrix-shaped thread model inside the existing IM APIs. It does not implement the full Matrix Client-Server protocol today. The goal is to adopt the useful shape of Matrix relationships while preserving the -current CSGClaw room, user, bot, auth, and local state model. +current CSGClaw room, user, participant, auth, and local state model. A thread is a sub-conversation in a room or DM. It starts from one existing top-level message, called the root message. The canonical thread ID is the root @@ -136,17 +136,17 @@ Thread-aware clients should apply `thread.created` and `thread.updated` to the root message summary and thread list, while applying `message.created` to the main timeline only when the message is not a thread reply. -## Bot Compatibility and PicoClaw +## Participant Bridge and PicoClaw -The bot compatibility API is the bridge used by PicoClaw-style integrations and -the Codex bridge: +The participant API is the message bridge used by PicoClaw-style integrations +and the Codex bridge: ```text -GET /api/bots/{id}/events -POST /api/bots/{id}/messages/send +GET /api/v1/channels/csgclaw/participants/{id}/events +POST /api/v1/channels/csgclaw/participants/{id}/messages ``` -Thread-aware bot events may include: +Thread-aware participant events may include: - `thread_root_id`: root message ID when the event is inside a thread. - `thread_context`: hidden context snapshot and summary for the thread root. @@ -155,12 +155,12 @@ Thread-aware bot events may include: `thread_context` is prompt context, not visible thread history. -Bot sends may include either CSGClaw fields (`room_id`, `text`, +Participant sends may include either CSGClaw fields (`room_id`, `text`, `thread_root_id`) or PicoClaw outbound fields (`chat_id`, `content`, `context.topic_id`). When a thread root/topic is present, the message is sent as -a reply in that thread. Bot sends that omit `thread_root_id`, `topic_id`, and +a reply in that thread. Participant sends that omit `thread_root_id`, `topic_id`, and `context.topic_id` are treated as top-level room/DM messages; CSGClaw does not -infer a thread from the bot's most recent room event. +infer a thread from the participant's most recent room event. This maps to PicoClaw/topic isolation requirements: a runtime should treat `room_id` as the normal conversation key and `room_id:thread_root_id` as the diff --git a/docs/im-threads.zh.md b/docs/im-threads.zh.md index 833a2b6a..955e454d 100644 --- a/docs/im-threads.zh.md +++ b/docs/im-threads.zh.md @@ -1,13 +1,13 @@ # CSGClaw IM Threads 本文说明 CSGClaw 本地 IM 的 thread 设计,方便维护者理解 thread 在存储、 -API、bot 兼容层、agent 上下文和 Web UI 中的协作方式。 +API、participant bridge、agent 上下文和 Web UI 中的协作方式。 ## 摘要 CSGClaw 在现有 IM API 内增量采用 Matrix 形状的 thread 模型,但当前不实现 完整 Matrix Client-Server 协议。这样可以复用 Matrix `m.thread` 关系语义, -同时保留 CSGClaw 现有的 room、user、bot、auth 和本地状态模型。 +同时保留 CSGClaw 现有的 room、user、participant、auth 和本地状态模型。 一个 thread 是 room 或 DM 内的子会话。它从一条已有顶层消息开启,这条消息 称为 root message。规范 thread ID 就是 root message ID。 @@ -127,16 +127,16 @@ Thread-aware 客户端应把 `thread.created` 和 `thread.updated` 应用到 roo message summary 和 thread 列表;处理 `message.created` 时,只有非 thread reply 才进入主时间线。 -## Bot 兼容与 PicoClaw +## Participant bridge 与 PicoClaw -Bot 兼容 API 是 PicoClaw 风格集成和 Codex bridge 使用的消息桥: +Participant API 是 PicoClaw 风格集成和 Codex bridge 使用的消息桥: ```text -GET /api/bots/{id}/events -POST /api/bots/{id}/messages/send +GET /api/v1/channels/csgclaw/participants/{id}/events +POST /api/v1/channels/csgclaw/participants/{id}/messages ``` -Thread-aware bot event 可能包含: +Thread-aware participant event 可能包含: - `thread_root_id`:事件位于 thread 内时的 root message ID。 - `thread_context`:该 thread root 的隐藏上下文快照和 summary。 @@ -145,11 +145,11 @@ Thread-aware bot event 可能包含: `thread_context` 是 prompt context,不是可见 thread 历史。 -Bot send 可以传入 CSGClaw 字段(`room_id`、`text`、`thread_root_id`), +Participant send 可以传入 CSGClaw 字段(`room_id`、`text`、`thread_root_id`), 也可以传入 PicoClaw outbound 字段(`chat_id`、`content`、 `context.topic_id`)。存在 thread root/topic 时,消息会作为该 thread 内的 -reply 发送。如果 bot send 同时省略 `thread_root_id`、`topic_id` 和 -`context.topic_id`,CSGClaw 会按 room/DM 顶层消息处理,不会根据该 bot 在 +reply 发送。如果 participant send 同时省略 `thread_root_id`、`topic_id` 和 +`context.topic_id`,CSGClaw 会按 room/DM 顶层消息处理,不会根据该 participant 在 房间中最近收到的事件推断 thread。 这对应 PicoClaw/topic 隔离需求:runtime 应把 `room_id` 视为普通会话 key, diff --git a/docs/participant-architecture.md b/docs/participant-architecture.md index f26ede37..a22806b3 100644 --- a/docs/participant-architecture.md +++ b/docs/participant-architecture.md @@ -150,6 +150,8 @@ Rules: an Agent ID, the server generates it as `u-{participant_id}`. This preserves the old worker/bot ID habit; for example participant `qa` maps to agent `u-qa`. +- The bootstrap manager is the reserved exception: its default CSGClaw + participant ID is `manager`, while its Agent ID remains `u-manager`. - If the caller specifies an Agent ID explicitly, it must still be globally unique and does not need to match the participant ID. Cross-channel reuse usually passes an existing `agent_id`. @@ -906,7 +908,8 @@ CLI field renames should match the API: - Add a Participant ID generator: derive readable slugs from explicit `id` or stable keys, add short random suffixes on collision, never derive IDs from editable `name`, and keep the default Agent ID for newly created agent-backed - participants as `u-{participant_id}`. + participants as `u-{participant_id}`, except the bootstrap manager + participant `manager` whose Agent ID is `u-manager`. - Replace the public `User` API with participant APIs. Keep only an internal channel identity/profile store where the CSGClaw and external channel adapters need one. diff --git a/docs/participant-architecture.zh.md b/docs/participant-architecture.zh.md index 4d323bb6..065845b3 100644 --- a/docs/participant-architecture.zh.md +++ b/docs/participant-architecture.zh.md @@ -80,6 +80,7 @@ Agent - Agent ID 全局唯一。 - 新建 agent-backed participant 时,如果请求未显式指定 Agent ID,服务端按 `u-{participant_id}` 生成 Agent ID。这个关系保持旧 worker/bot ID 的习惯,例如 participant `qa` 对应 agent `u-qa`。 +- Bootstrap manager 是保留例外:默认 CSGClaw participant ID 为 `manager`,Agent ID 仍为 `u-manager`。 - 如果调用方显式指定 Agent ID,仍必须满足全局唯一,并且不要求和 participant ID 相同;跨 channel 复用时通常显式传已有 `agent_id`。 - Agent 生命周期操作继续放在 `/api/v1/agents`。 - Agent profile、model、runtime、日志、start、stop、restart、recreate 仍由 agent service 管理。 @@ -671,7 +672,7 @@ CLI 字段改名应和 API 一致: - 新增 participant request/response types。 - 新增 participant storage,规范 key 为 `(channel, id)`。 -- 新增 Participant ID 生成器:从显式 `id` 或稳定 key 生成可读 slug,冲突时追加短随机后缀;不要从可修改的 `name` 派生 ID;新建 agent-backed participant 的默认 Agent ID 保持 `u-{participant_id}`。 +- 新增 Participant ID 生成器:从显式 `id` 或稳定 key 生成可读 slug,冲突时追加短随机后缀;不要从可修改的 `name` 派生 ID;新建 agent-backed participant 的默认 Agent ID 保持 `u-{participant_id}`,但 bootstrap manager 例外,participant 为 `manager`、Agent 为 `u-manager`。 - 用 participant API 替换公开 `User` API。只有 CSGClaw 和外部 channel adapter 需要时,才保留内部 channel identity/profile store。 - 新增 participant service,支持 list、get、create、patch、delete 和 agent binding。 - 注册 `/api/v1/channels/{channel}/participants`。 diff --git a/docs/sandbox/csghub.md b/docs/sandbox/csghub.md index 93528807..690b807f 100644 --- a/docs/sandbox/csghub.md +++ b/docs/sandbox/csghub.md @@ -43,7 +43,7 @@ It has two audiences: │ manager pod │ csgclaw-agent-sandbox │ picoclaw │──────────────┐ │ └──────┬───────┘ │ │ - POST /api/bots/:id/worker │ │ │ + POST /api/v1/channels/csgclaw/participants │ │ │ ▼ │ │ ┌──────────────┐ │ │ │ worker pod │ csgclaw-agent-sandbox @@ -142,7 +142,7 @@ the CSGHub API env before sending the CSGHub `CreateRequest`. | Picoclaw ↔ server | `CSGCLAW_ACCESS_TOKEN` | `server.AccessToken` | | Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_BASE_URL` | `resolveManagerBaseURL(server)` | | Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN` | `server.AccessToken` | -| Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_BOT_ID` | per-agent | +| Picoclaw ↔ server | `PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID` | per-agent | | Picoclaw ↔ server | `CSGCLAW_LLM_BASE_URL` | `llmBridgeBaseURL(...)` | | Picoclaw ↔ server | `CSGCLAW_LLM_API_KEY` | `server.AccessToken` | | Picoclaw ↔ server | `CSGCLAW_LLM_MODEL_ID` | per-agent | @@ -179,9 +179,10 @@ values and optionally prefixes them with `CSGCLAW_PVC_SUBPATH_PREFIX`. - Every sandbox (server + manager + worker) must share an overlay reachable by pod-IP or Hub service DNS; the server's advertised URL must resolve from inside manager/worker pods. -- Manager/worker pods must reach the server on - `CSGCLAW_BASE_URL` (LLM bridge `/api/bots//llm`, worker spawn - `/api/bots//workers`, health `/healthz`). +- Manager/worker pods must reach the server on `CSGCLAW_BASE_URL`: + participant message bridge + `/api/v1/channels/csgclaw/participants//{events,messages}`, + LLM bridge `/api/v1/agents//llm`, and health `/healthz`. - Server pod must reach the CSGHub Sandbox API on `CSGHUB_API_BASE_URL` (TLS + bearer). - The server-side CSGHub client uses `CSGHUB_AIGATEWAY_URL` when set; diff --git a/internal/agent/manager_config.go b/internal/agent/manager_config.go index d0d30258..1e00f4ea 100644 --- a/internal/agent/manager_config.go +++ b/internal/agent/manager_config.go @@ -1,6 +1,7 @@ package agent import ( + "encoding/json" "fmt" "log/slog" "net" @@ -31,15 +32,19 @@ var defaultLocalIPDetector = localIPDetector{ } func ensureManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) (string, error) { - return ensureAgentPicoClawConfig(ManagerName, "u-manager", server, model) + return ensureAgentPicoClawConfigForParticipant(ManagerName, ManagerParticipantID, ManagerUserID, server, model) } -func ensureAgentPicoClawConfig(agentName, botID string, server config.ServerConfig, model config.ModelConfig) (string, error) { +func ensureAgentPicoClawConfig(agentName, agentID string, server config.ServerConfig, model config.ModelConfig) (string, error) { + return ensureAgentPicoClawConfigForParticipant(agentName, agentID, agentID, server, model) +} + +func ensureAgentPicoClawConfigForParticipant(agentName, participantID, agentID string, server config.ServerConfig, model config.ModelConfig) (string, error) { agentHome, err := agentHomeDir(agentName) if err != nil { return "", err } - return picoclawsandbox.EnsureConfig(agentHome, botID, server, model, resolveManagerBaseURL) + return picoclawsandbox.EnsureConfig(agentHome, participantID, agentID, server, model, resolveManagerBaseURL) } func managerPicoClawRoot() (string, error) { @@ -59,11 +64,52 @@ func agentPicoClawRoot(agentName string) (string, error) { } func renderManagerPicoClawConfig(server config.ServerConfig, model config.ModelConfig) ([]byte, error) { - return renderAgentPicoClawConfig("u-manager", server, model) + return renderAgentPicoClawConfigForParticipant(ManagerParticipantID, ManagerUserID, server, model) } -func renderAgentPicoClawConfig(botID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { - return picoclawsandbox.RenderConfig(botID, server, model, resolveManagerBaseURL) +func renderAgentPicoClawConfig(agentID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { + return renderAgentPicoClawConfigForParticipant(agentID, agentID, server, model) +} + +func renderAgentPicoClawConfigForParticipant(participantID, agentID string, server config.ServerConfig, model config.ModelConfig) ([]byte, error) { + return picoclawsandbox.RenderConfig(participantID, agentID, server, model, resolveManagerBaseURL) +} + +func agentPicoClawConfigNeedsParticipantRecreate(agentName, participantID string) bool { + root, err := agentPicoClawRoot(agentName) + if err != nil { + return false + } + data, err := os.ReadFile(filepath.Join(root, picoclawsandbox.HostConfig)) + if err != nil { + return false + } + + var cfg struct { + Channels map[string]json.RawMessage `json:"channels"` + } + if err := json.Unmarshal(data, &cfg); err != nil { + return false + } + raw := cfg.Channels["csgclaw"] + if len(raw) == 0 { + return true + } + var channel map[string]any + if err := json.Unmarshal(raw, &channel); err != nil { + return false + } + if enabled, ok := channel["enabled"].(bool); !ok || !enabled { + return true + } + got, ok := channel["participant_id"].(string) + if !ok || strings.TrimSpace(got) != strings.TrimSpace(participantID) { + return true + } + if _, ok := channel["bot_id"]; ok { + return true + } + return false } func picoclawBridgeModelID(modelID string) string { diff --git a/internal/agent/manager_config_test.go b/internal/agent/manager_config_test.go index 69bdd677..f2c0f825 100644 --- a/internal/agent/manager_config_test.go +++ b/internal/agent/manager_config_test.go @@ -59,9 +59,10 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { for _, want := range []string{ `"model_name": "gpt-5.4"`, `"model": "openai/gpt-5.4"`, - `"api_base": "http://10.0.0.8:18080/api/bots/u-ux/llm"`, + `"api_base": "http://10.0.0.8:18080/api/v1/agents/u-ux/llm"`, `"api_key": "shared-token"`, - `"bot_id": "u-ux"`, + `"participant_id": "u-ux"`, + `"enabled": true`, } { if !strings.Contains(text, want) { t.Fatalf("renderAgentPicoClawConfig() missing %q in:\n%s", want, text) @@ -70,7 +71,9 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { if strings.Contains(text, "cloud.infini-ai.com") { t.Fatalf("renderAgentPicoClawConfig() leaked upstream base URL:\n%s", text) } - + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("renderAgentPicoClawConfig() still emitted bot_id:\n%s", text) + } var rendered map[string]any if err := json.Unmarshal(data, &rendered); err != nil { t.Fatalf("renderAgentPicoClawConfig() produced invalid JSON: %v", err) @@ -99,6 +102,63 @@ func TestRenderAgentPicoClawConfigUsesBridgeModelEndpoint(t *testing.T) { } } +func TestRenderManagerPicoClawConfigUsesSeparateParticipantAndAgentIDs(t *testing.T) { + localIPv4Resolver = func() string { return "10.0.0.8" } + defer func() { localIPv4Resolver = localIPv4 }() + + data, err := renderManagerPicoClawConfig(config.ServerConfig{ + ListenAddr: "0.0.0.0:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + }) + if err != nil { + t.Fatalf("renderManagerPicoClawConfig() error = %v", err) + } + + text := string(data) + for _, want := range []string{ + `"participant_id": "` + ManagerParticipantID + `"`, + `"api_base": "http://10.0.0.8:18080/api/v1/agents/` + ManagerUserID + `/llm"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("renderManagerPicoClawConfig() missing %q in:\n%s", want, text) + } + } + if strings.Contains(text, `"api_base": "http://10.0.0.8:18080/api/v1/agents/`+ManagerParticipantID+`/llm"`) { + t.Fatalf("renderManagerPicoClawConfig() used participant ID for LLM bridge:\n%s", text) + } + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("renderManagerPicoClawConfig() still emitted bot_id:\n%s", text) + } +} + +func TestAgentPicoClawConfigNeedsParticipantRecreateRejectsLegacyBotID(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + configPath := filepath.Join(homeDir, config.AppDirName, managerAgentsDirName, ManagerName, picoclawsandbox.HostDir, picoclawsandbox.HostConfig) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + + staleConfig := `{"channels":{"csgclaw":{"enabled":true,"participant_id":"manager","bot_id":"manager"}}}` + if err := os.WriteFile(configPath, []byte(staleConfig), 0o600); err != nil { + t.Fatalf("WriteFile(stale config) error = %v", err) + } + if !agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) { + t.Fatal("agentPicoClawConfigNeedsParticipantRecreate() = false, want true for legacy bot_id field") + } + + currentConfig := `{"channels":{"csgclaw":{"enabled":true,"participant_id":"manager"}}}` + if err := os.WriteFile(configPath, []byte(currentConfig), 0o600); err != nil { + t.Fatalf("WriteFile(current config) error = %v", err) + } + if agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) { + t.Fatal("agentPicoClawConfigNeedsParticipantRecreate() = true, want false for current participant bridge fields") + } +} + func stringifyJSONList(values []any) []string { result := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/agent/runtime_state.go b/internal/agent/runtime_state.go index e3caeec2..09798a1a 100644 --- a/internal/agent/runtime_state.go +++ b/internal/agent/runtime_state.go @@ -390,9 +390,9 @@ func ProjectsRoot() (string, error) { return ensureAgentProjectsRoot() } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } func bridgeLLMEnvVars(llmBaseURL, accessToken, modelID string) map[string]string { diff --git a/internal/agent/runtime_state_test.go b/internal/agent/runtime_state_test.go index d40a4f78..e5adf936 100644 --- a/internal/agent/runtime_state_test.go +++ b/internal/agent/runtime_state_test.go @@ -58,7 +58,7 @@ func TestRuntimeProfileForAgentUsesBridgeForCodex(t *testing.T) { }, }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("runtimeProfileForAgent().BaseURL = %q, want %q", got, want) } if got, want := profile.APIKey, "shared-token"; got != want { @@ -96,7 +96,7 @@ func TestRuntimeProfileForKindUsesBridgeForCodexRuntime(t *testing.T) { }, }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("runtimeProfileForKind().BaseURL = %q, want %q", got, want) } if got, want := profile.APIKey, "shared-token"; got != want { @@ -136,7 +136,7 @@ func TestRuntimeProfileForKindUsesHostReachableBridgeForCodexRuntime(t *testing. ModelID: "gpt-5.4", }) - if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-developer/llm"; got != want { + if got, want := profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-developer/llm"; got != want { t.Fatalf("runtimeProfileForKind().BaseURL = %q, want host-reachable %q", got, want) } } diff --git a/internal/agent/service.go b/internal/agent/service.go index 434b951f..86825bcf 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -24,14 +24,15 @@ import ( ) const ( - ManagerName = "manager" - ManagerUserID = "u-manager" - managerHostPort = 18790 - managerGuestPort = 18790 - managerDebugMode = true - hostWorkspaceDir = "workspace" - hostProjectsDir = "projects" - gatewayLogPoll = 200 * time.Millisecond + ManagerName = "manager" + ManagerParticipantID = "manager" + ManagerUserID = "u-manager" + managerHostPort = 18790 + managerGuestPort = 18790 + managerDebugMode = true + hostWorkspaceDir = "workspace" + hostProjectsDir = "projects" + gatewayLogPoll = 200 * time.Millisecond ) const ( @@ -398,10 +399,14 @@ func (svc *Service) EnsureBootstrapManager(ctx context.Context, forceRecreate bo if err != nil { return err } - if _, err := ensureAgentPicoClawConfig(ManagerName, ManagerUserID, svc.server, defaultModel); err != nil { + recreateForParticipantBridgeConfig := !forceRecreate && agentPicoClawConfigNeedsParticipantRecreate(ManagerName, ManagerParticipantID) + if _, err := ensureAgentPicoClawConfigForParticipant(ManagerName, ManagerParticipantID, ManagerUserID, svc.server, defaultModel); err != nil { return err } - _, err = svc.EnsureManager(ctx, forceRecreate) + if recreateForParticipantBridgeConfig { + log.Printf("bootstrap manager PicoClaw config uses legacy bot bridge fields; recreating manager to load participant bridge config") + } + _, err = svc.EnsureManager(ctx, forceRecreate || recreateForParticipantBridgeConfig) return err } @@ -497,10 +502,11 @@ func (s *Service) ensureManager(ctx context.Context, forceRecreate bool, imageOv return err } if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ - RuntimeID: runtimeIDForAgentID(ManagerUserID), - AgentID: ManagerUserID, - AgentName: ManagerName, - Profile: s.runtimeProfileForKind(runtimeKind, ManagerUserID, ManagerName, "", startProfile), + RuntimeID: runtimeIDForAgentID(ManagerUserID), + AgentID: ManagerUserID, + ParticipantID: ManagerParticipantID, + AgentName: ManagerName, + Profile: s.runtimeProfileForKind(runtimeKind, ManagerUserID, ManagerName, "", startProfile), }); err != nil { return fmt.Errorf("provision bootstrap manager runtime: %w", err) } @@ -1555,6 +1561,7 @@ func (s *Service) CreateWorker(ctx context.Context, spec CreateAgentSpec) (Agent if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ RuntimeID: runtimeIDForAgentID(id), AgentID: id, + ParticipantID: participantIDForAgent(name, id), AgentName: name, Profile: runtimeProfile, WorkspaceOverlay: strings.TrimSpace(spec.FromTemplate), @@ -1757,12 +1764,34 @@ func (s *Service) provisionRuntimeForAgent(ctx context.Context, rt agentruntime. return s.provisionRuntime(ctx, rt, strings.TrimSpace(got.RuntimeKind), agentruntime.ProvisionRequest{ RuntimeID: normalizeRuntimeID(got.RuntimeID, got.ID), AgentID: strings.TrimSpace(got.ID), + ParticipantID: participantIDForAgent(got.Name, got.ID), AgentName: strings.TrimSpace(got.Name), Profile: s.runtimeProfileForAgent(got), WorkspaceOverlay: strings.TrimSpace(workspaceOverlay), }) } +func participantIDForAgent(agentName, agentID string) string { + agentID = strings.TrimSpace(agentID) + if managerGatewayMatch(agentName, agentID) { + return ManagerParticipantID + } + return participantIDFromAgentID(agentID) +} + +func ParticipantIDForAgent(agentName, agentID string) string { + return participantIDForAgent(agentName, agentID) +} + +func participantIDFromAgentID(agentID string) string { + agentID = strings.TrimSpace(agentID) + withoutPrefix := strings.TrimPrefix(agentID, "u-") + if withoutPrefix != "" && withoutPrefix != agentID { + return withoutPrefix + } + return agentID +} + func (s *Service) gatewayProvisionRequest(runtimeKind, agentName, agentID string) (*agentruntime.GatewayProvision, error) { if s == nil { return nil, fmt.Errorf("agent service is required") diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 1d8510b3..6f9e2f15 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -399,10 +399,11 @@ func (s *Service) recreate(ctx context.Context, id string, imageFor func(context return Agent{}, fmt.Errorf("refresh gateway template skills: %w", err) } if err := s.provisionRuntime(ctx, runtimeImpl, runtimeKind, agentruntime.ProvisionRequest{ - RuntimeID: createSpec.RuntimeID, - AgentID: createSpec.AgentID, - AgentName: createSpec.AgentName, - Profile: runtimeProfile, + RuntimeID: createSpec.RuntimeID, + AgentID: createSpec.AgentID, + ParticipantID: participantIDForAgent(createSpec.AgentName, createSpec.AgentID), + AgentName: createSpec.AgentName, + Profile: runtimeProfile, }); err != nil { return Agent{}, fmt.Errorf("provision agent runtime: %w", err) } diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 994f985a..99d12326 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -572,7 +572,7 @@ func TestCreateWorkerUsesCodexRuntimeWhenRequested(t *testing.T) { if spec.AgentName != "alice" { t.Fatalf("Create() agent name = %q, want %q", spec.AgentName, "alice") } - if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Create() profile base url = %q, want %q", got, want) } if got, want := spec.Profile.APIKey, "shared-token"; got != want { @@ -1107,7 +1107,7 @@ func TestCreateWorkerProvisionsRuntimeBeforeNew(t *testing.T) { if req.AgentName != "alice" { t.Fatalf("Provision() agent name = %q, want %q", req.AgentName, "alice") } - if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Provision() profile base url = %q, want %q", got, want) } if got, want := req.Profile.APIKey, "shared-token"; got != want { @@ -1149,6 +1149,47 @@ func TestCreateWorkerProvisionsRuntimeBeforeNew(t *testing.T) { } } +func TestCreateWorkerProvisionsParticipantIDSeparateFromAgentID(t *testing.T) { + var gotParticipantID string + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, "manager-image:test", "", + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindPicoClawSandbox, + provision: func(_ context.Context, req agentruntime.ProvisionRequest) error { + gotParticipantID = req.ParticipantID + return nil + }, + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-qa"}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + if _, err := svc.CreateWorker(context.Background(), CreateAgentSpec{ + ID: "u-agent-hhtz4b", + Name: "qa", + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-worker:dev", + AgentProfile: AgentProfile{ + Name: "qa", + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + }); err != nil { + t.Fatalf("CreateWorker() error = %v", err) + } + if got, want := gotParticipantID, "agent-hhtz4b"; got != want { + t.Fatalf("Provision() participant id = %q, want %q", got, want) + } +} + func TestCreateWorkerPassesWorkspaceOverlayToProvision(t *testing.T) { overlayRoot := t.TempDir() var gotOverlay string @@ -1198,7 +1239,7 @@ func TestRecreateTriggersLifecycleObserver(t *testing.T) { kind: RuntimeKindCodex, del: func(context.Context, agentruntime.Handle) error { return nil }, new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { - if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := spec.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Create() profile base url = %q, want %q", got, want) } if got, want := spec.Profile.APIKey, "shared-token"; got != want { @@ -1275,7 +1316,7 @@ func TestRecreateProvisionsRuntimeBeforeNew(t *testing.T) { if req.AgentName != "alice" { t.Fatalf("Provision() agent name = %q, want %q", req.AgentName, "alice") } - if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/bots/u-alice/llm"; got != want { + if got, want := req.Profile.BaseURL, "http://127.0.0.1:18080/api/v1/agents/u-alice/llm"; got != want { t.Fatalf("Provision() profile base url = %q, want %q", got, want) } if got, want := req.Profile.APIKey, "shared-token"; got != want { @@ -3668,11 +3709,14 @@ func TestStartRefreshesCompleteWorkerGatewayConfig(t *testing.T) { t.Fatalf("ReadFile(worker config) error = %v", err) } text := string(data) - for _, want := range []string{`"bot_id": "u-alice"`, `"model_name": "gpt-5.5"`, `"api_base": "http://10.0.0.8:18080/api/bots/u-alice/llm"`} { + for _, want := range []string{`"participant_id": "alice"`, `"model_name": "gpt-5.5"`, `"api_base": "http://10.0.0.8:18080/api/v1/agents/u-alice/llm"`} { if !strings.Contains(text, want) { t.Fatalf("worker config missing %q in:\n%s", want, text) } } + if strings.Contains(text, `"bot_id"`) { + t.Fatalf("worker config still emitted bot_id:\n%s", text) + } } func TestStartConfiguredAgentsRecreatesMissingCompleteWorkerBoxes(t *testing.T) { @@ -5316,6 +5360,112 @@ func TestEnsureBootstrapStateReusesStoredManagerBoxIDWithoutForce(t *testing.T) } } +func TestEnsureBootstrapStateRecreatesManagerWithLegacyPicoClawBridgeConfig(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + SetTestHooks(nil, nil) + defer ResetTestHooks() + + primaryRT := &fakeRuntime{} + testEnsureRuntimeAtHomeHook = func(_ *Service, home string) (sandbox.Runtime, error) { + return primaryRT, nil + } + testGetBoxHook = func(_ *Service, _ context.Context, rt sandbox.Runtime, idOrName string) (sandbox.Instance, error) { + if rt == primaryRT && idOrName == "box-old" { + return &fakeInfoInstance{info: sandbox.Info{ + ID: "box-old", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 2, 12, 0, 0, 0, time.UTC), + }}, nil + } + return nil, fmt.Errorf("%w: missing", sandbox.ErrNotFound) + } + var removed []string + testForceRemoveBoxHook = func(_ *Service, _ context.Context, _ sandbox.Runtime, idOrName string) error { + removed = append(removed, idOrName) + return nil + } + var created bool + testCreateGatewayBoxHook = func(_ *Service, _ context.Context, _ sandbox.Runtime, image, name, botID string, _ AgentProfile) (sandbox.Instance, sandbox.Info, error) { + created = true + if image != "manager-image:test" || name != ManagerName || botID != ManagerUserID { + t.Fatalf("createGatewayBox() got image=%q name=%q botID=%q", image, name, botID) + } + return &fakeInfoInstance{info: sandbox.Info{ + ID: "box-new", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + }}, sandbox.Info{ + ID: "box-new", + Name: ManagerName, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 4, 3, 12, 0, 0, 0, time.UTC), + }, nil + } + + managerHome := filepath.Join(homeDir, config.AppDirName, managerAgentsDirName, ManagerName) + configPath := filepath.Join(managerHome, picoclawsandbox.HostDir, picoclawsandbox.HostConfig) + if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil { + t.Fatalf("MkdirAll(config dir) error = %v", err) + } + legacyConfig := `{"channels":{"csgclaw":{"enabled":true,"bot_id":"u-manager"}}}` + if err := os.WriteFile(configPath, []byte(legacyConfig), 0o600); err != nil { + t.Fatalf("WriteFile(legacy config) error = %v", err) + } + + statePath := filepath.Join(t.TempDir(), "agents.json") + data, err := json.Marshal(persistedState{ + Agents: []persistedAgent{ + { + ID: ManagerUserID, + Name: ManagerName, + RuntimeKind: RuntimeKindPicoClawSandbox, + Role: RoleManager, + BoxID: "box-old", + CreatedAt: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + AgentProfile: AgentProfile{ + Name: ManagerName, + Provider: ProviderAPI, + BaseURL: "https://api.example/v1", + APIKey: "api-key", + ModelID: "gpt-4.1", + ProfileComplete: true, + }, + ProfileComplete: true, + }, + }, + }) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + if err := os.WriteFile(statePath, data, 0o600); err != nil { + t.Fatalf("os.WriteFile() error = %v", err) + } + + if err := EnsureBootstrapState(context.Background(), statePath, config.ServerConfig{ListenAddr: ":18080", AccessToken: "token"}, testModelConfig(), "manager-image:test", false); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + if !created { + t.Fatal("createGatewayBox() was not called; legacy manager bridge config should force recreate") + } + if got, want := strings.Join(removed, ","), "box-old"; got != want { + t.Fatalf("removed boxes = %q, want %q", got, want) + } + rendered, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("ReadFile(rendered config) error = %v", err) + } + if !strings.Contains(string(rendered), `"participant_id": "`+ManagerParticipantID+`"`) { + t.Fatalf("rendered config missing participant_id:\n%s", rendered) + } + if strings.Contains(string(rendered), `"bot_id"`) { + t.Fatalf("rendered config still contains bot_id:\n%s", rendered) + } +} + func TestBoxRuntimeHomeUsesPerAgentDirectory(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) @@ -5622,7 +5772,7 @@ func TestGatewayCreateSpecBuildsSandboxSpec(t *testing.T) { if got, want := spec.Env["CSGCLAW_BASE_URL"], "http://10.0.0.8:18080"; got != want { t.Fatalf("CSGCLAW_BASE_URL = %q, want %q", got, want) } - if got, want := spec.Env["CSGCLAW_LLM_BASE_URL"], "http://10.0.0.8:18080/api/bots/u-worker-1/llm"; got != want { + if got, want := spec.Env["CSGCLAW_LLM_BASE_URL"], "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm"; got != want { t.Fatalf("CSGCLAW_LLM_BASE_URL = %q, want %q", got, want) } if got, want := spec.Env["PICOCLAW_CHANNELS_FEISHU_APP_ID"], "cli_worker"; got != want { @@ -6065,33 +6215,38 @@ func TestPicoclawBoxEnvVars(t *testing.T) { "http://10.0.0.8:18080", "shared-token", "u-worker-1", - "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "u-worker-1", + "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", "minimax-m2.7", ) wants := map[string]string{ - "CSGCLAW_BASE_URL": "http://10.0.0.8:18080", - "CSGCLAW_ACCESS_TOKEN": "shared-token", - "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": "http://10.0.0.8:18080", - "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": "shared-token", - "PICOCLAW_CHANNELS_CSGCLAW_BOT_ID": "u-worker-1", - "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", - "CSGCLAW_LLM_API_KEY": "shared-token", - "CSGCLAW_LLM_MODEL_ID": "minimax-m2.7", - "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", - "OPENAI_API_KEY": "shared-token", - "OPENAI_MODEL": "minimax-m2.7", - "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": "minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_NAME": "minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_ID": "openai/minimax-m2.7", - "PICOCLAW_CUSTOM_MODEL_API_KEY": "shared-token", - "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "CSGCLAW_BASE_URL": "http://10.0.0.8:18080", + "CSGCLAW_ACCESS_TOKEN": "shared-token", + "PICOCLAW_CHANNELS_CSGCLAW_BASE_URL": "http://10.0.0.8:18080", + "PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN": "shared-token", + "PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID": "u-worker-1", + "PICOCLAW_CHANNELS_CSGCLAW_ENABLED": "true", + "CSGCLAW_LLM_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", + "CSGCLAW_LLM_API_KEY": "shared-token", + "CSGCLAW_LLM_MODEL_ID": "minimax-m2.7", + "OPENAI_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", + "OPENAI_API_KEY": "shared-token", + "OPENAI_MODEL": "minimax-m2.7", + "PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME": "minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_NAME": "minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_ID": "openai/minimax-m2.7", + "PICOCLAW_CUSTOM_MODEL_API_KEY": "shared-token", + "PICOCLAW_CUSTOM_MODEL_BASE_URL": "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", } for key, want := range wants { if got[key] != want { t.Fatalf("%s = %q, want %q", key, got[key], want) } } + if _, ok := got["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } } func TestPicoclawBoxEnvVarsPrefixesCustomModelIDForSlashNames(t *testing.T) { @@ -6099,7 +6254,8 @@ func TestPicoclawBoxEnvVarsPrefixesCustomModelIDForSlashNames(t *testing.T) { "http://10.0.0.8:18080", "shared-token", "u-worker-1", - "http://10.0.0.8:18080/api/bots/u-worker-1/llm", + "u-worker-1", + "http://10.0.0.8:18080/api/v1/agents/u-worker-1/llm", "Qwen/Qwen3-0.6B-GGUF", ) @@ -6143,14 +6299,15 @@ func TestAddFeishuBoxEnvVarsRequiresExactBotIDMatch(t *testing.T) { } } -func picoclawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string) map[string]string { +func picoclawBoxEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) picoclawModelID := picoclawBridgeModelID(modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken env["PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"] = baseURL env["PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"] = accessToken - env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"] = botID + env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"] = participantID + env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"] = "true" env["PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_ID"] = picoclawModelID @@ -6230,9 +6387,9 @@ func withTestSandboxRuntimeHost(host PicoClawRuntimeHost, provider feishu.BotCre }, nil }, SyncHandle: host.SyncHandle, - BuildRuntimeEnv: func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { - env := picoclawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID) - addFeishuBoxEnvVars(env, botID, provider) + BuildRuntimeEnv: func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { + env := picoclawBoxEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID) + addFeishuBoxEnvVars(env, agentID, provider) return env }, AddProfileEnv: addProfileEnvVars, diff --git a/internal/api/bot_compat.go b/internal/api/bot_compat.go index aa222c93..5402dc84 100644 --- a/internal/api/bot_compat.go +++ b/internal/api/bot_compat.go @@ -11,10 +11,10 @@ import ( "strings" "time" - "github.com/go-chi/chi/v5" - "csgclaw/internal/agent" + "csgclaw/internal/apitypes" "csgclaw/internal/im" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" ) @@ -23,21 +23,6 @@ const ( botHeartbeatInterval = 15 * time.Second ) -func (h *Handler) registerBotCompatibilityRoutes(router chi.Router) { - router.Route("/api/bots/{id}", func(r chi.Router) { - r.Get("/events", h.handleBotCompatibilityEvents) - r.Post("/messages/send", h.handleBotCompatibilitySendMessage) - r.Get("/llm/models", h.handleBotCompatibilityLLMModels) - r.Get("/llm/v1/models", h.handleBotCompatibilityLLMModels) - r.Post("/llm/chat/completions", h.handleBotCompatibilityLLMChatCompletions) - r.Post("/llm/v1/chat/completions", h.handleBotCompatibilityLLMChatCompletions) - r.Get("/llm/responses", h.handleBotCompatibilityLLMResponsesWebsocket) - r.Get("/llm/v1/responses", h.handleBotCompatibilityLLMResponsesWebsocket) - r.Post("/llm/responses", h.handleBotCompatibilityLLMResponses) - r.Post("/llm/v1/responses", h.handleBotCompatibilityLLMResponses) - }) -} - func (h *Handler) PublishBotEvent(evt im.Event) { if h.botBridge == nil || h.im == nil { return @@ -57,76 +42,211 @@ func (h *Handler) PublishBotEvent(evt im.Event) { h.reconnectMissedBotAgents(evt.Sender.ID, missed) return } - missed := h.botBridge.PublishMessageEvent(room, *evt.Sender, *evt.Message) + missed := h.publishMessageBotEvent(room, *evt.Sender, *evt.Message) h.reconnectMissedBotAgents(evt.Sender.ID, missed) } -func (h *Handler) handleBotCompatibilityEvents(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +type botBridgeTarget struct { + bridgeID string + aliases []string +} + +func newBotBridgeTarget(bridgeID string, aliases ...string) botBridgeTarget { + bridgeID = strings.TrimSpace(bridgeID) + if bridgeID == "" { + return botBridgeTarget{} + } + seen := map[string]struct{}{bridgeID: {}} + out := botBridgeTarget{ + bridgeID: bridgeID, + aliases: []string{bridgeID}, + } + for _, alias := range aliases { + alias = strings.TrimSpace(alias) + if alias == "" { + continue + } + if _, ok := seen[alias]; ok { + continue + } + seen[alias] = struct{}{} + out.aliases = append(out.aliases, alias) } - h.handleBotEvents(w, r, botID) + return out } -func (h *Handler) handleBotCompatibilitySendMessage(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +func (t botBridgeTarget) matches(id string) bool { + id = strings.TrimSpace(id) + if id == "" { + return false + } + for _, alias := range t.aliases { + if strings.TrimSpace(alias) == id { + return true + } } - h.handleBotSendMessage(w, r, botID) + return false } -func (h *Handler) handleBotCompatibilityLLMModels(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +func (h *Handler) publishMessageBotEvent(room im.Room, sender im.User, message im.Message) []string { + var missed []string + for _, target := range h.botBridgeTargetsForRoom(room) { + if !h.enqueueBotMessageEventForBridgeTarget(room, sender, message, target, "") { + missed = append(missed, target.bridgeID) + } } - h.handleBotLLMModels(w, r, botID) + return missed } -func (h *Handler) handleBotCompatibilityLLMChatCompletions(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +func (h *Handler) enqueueBotMessageEventForBridgeID(room im.Room, sender im.User, message im.Message, bridgeID string, text string) bool { + return h.enqueueBotMessageEventForBridgeTarget(room, sender, message, h.botBridgeTargetForBridgeID(bridgeID), text) +} + +func (h *Handler) enqueueBotMessageEventForBridgeTarget(room im.Room, sender im.User, message im.Message, target botBridgeTarget, text string) bool { + if h == nil || h.botBridge == nil || strings.TrimSpace(target.bridgeID) == "" { + return true + } + if target.matches(message.SenderID) { + return true + } + deliveryRoom := roomForBotBridgeTarget(room, target) + deliveryMessage := messageForBotBridgeTarget(message, target) + if strings.TrimSpace(text) != "" { + return h.botBridge.EnqueueMessageEventWithText(deliveryRoom, sender, deliveryMessage, target.bridgeID, text) } - h.handleBotLLMChatCompletions(w, r, botID) + return h.botBridge.EnqueueMessageEvent(deliveryRoom, sender, deliveryMessage, target.bridgeID) } -func (h *Handler) handleBotCompatibilityLLMResponses(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +func (h *Handler) botBridgeTargetsForRoom(room im.Room) []botBridgeTarget { + targets := make([]botBridgeTarget, 0, len(room.Members)) + seen := make(map[string]struct{}, len(room.Members)) + for _, memberID := range room.Members { + target := h.botBridgeTargetForRoomMember(memberID) + if strings.TrimSpace(target.bridgeID) == "" { + continue + } + if _, ok := seen[target.bridgeID]; ok { + continue + } + seen[target.bridgeID] = struct{}{} + targets = append(targets, target) } - h.handleBotLLMResponses(w, r, botID) + return targets } -func (h *Handler) handleBotCompatibilityLLMResponsesWebsocket(w http.ResponseWriter, r *http.Request) { - botID, ok := h.requireBotCompatibilityBotID(w, r) - if !ok { - return +func (h *Handler) botBridgeTargetForRoomMember(memberID string) botBridgeTarget { + memberID = strings.TrimSpace(memberID) + if memberID == "" { + return botBridgeTarget{} + } + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, memberID); ok && isCSGClawAgentParticipant(item) { + return botBridgeTargetForParticipant(item, memberID) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, memberID) { + continue + } + return botBridgeTargetForParticipant(item, memberID) + } + } + return newBotBridgeTarget(memberID, memberID) +} + +func (h *Handler) botBridgeTargetForBridgeID(bridgeID string) botBridgeTarget { + bridgeID = strings.TrimSpace(bridgeID) + if bridgeID == "" { + return botBridgeTarget{} } - h.handleBotLLMResponsesWebsocket(w, r, botID) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, bridgeID); ok && isCSGClawAgentParticipant(item) { + return botBridgeTargetForParticipant(item, bridgeID) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, bridgeID) { + continue + } + return botBridgeTargetForParticipant(item, bridgeID) + } + } + if bridgeID == agent.ManagerParticipantID { + return newBotBridgeTarget(agent.ManagerParticipantID, agent.ManagerUserID) + } + return newBotBridgeTarget(bridgeID, bridgeID) +} + +func botBridgeTargetForParticipant(item apitypes.Participant, aliases ...string) botBridgeTarget { + allAliases := []string{item.ID, item.ChannelUserRef, item.AgentID} + allAliases = append(allAliases, aliases...) + return newBotBridgeTarget(item.ID, allAliases...) +} + +func isCSGClawAgentParticipant(item apitypes.Participant) bool { + return strings.TrimSpace(item.ID) != "" && + strings.EqualFold(strings.TrimSpace(item.Channel), participant.ChannelCSGClaw) && + strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeAgent) +} + +func participantMatchesIdentity(item apitypes.Participant, id string) bool { + id = strings.TrimSpace(id) + return id != "" && (strings.TrimSpace(item.ID) == id || + strings.TrimSpace(item.ChannelUserRef) == id || + strings.TrimSpace(item.AgentID) == id) } -func (h *Handler) requireBotCompatibilityBotID(w http.ResponseWriter, r *http.Request) (string, bool) { - botID := pathValue(r, "id") - if botID == "" { - http.NotFound(w, r) - return "", false +func roomForBotBridgeTarget(room im.Room, target botBridgeTarget) im.Room { + if strings.TrimSpace(target.bridgeID) == "" { + return room + } + out := room + out.Members = make([]string, 0, len(room.Members)) + seen := make(map[string]struct{}, len(room.Members)) + for _, memberID := range room.Members { + deliveryID := strings.TrimSpace(memberID) + if target.matches(deliveryID) { + deliveryID = target.bridgeID + } + if deliveryID == "" { + continue + } + if _, ok := seen[deliveryID]; ok { + continue + } + seen[deliveryID] = struct{}{} + out.Members = append(out.Members, deliveryID) + } + return out +} + +func messageForBotBridgeTarget(message im.Message, target botBridgeTarget) im.Message { + if strings.TrimSpace(target.bridgeID) == "" || len(target.aliases) == 0 { + return message } - if h.botBridge == nil { - http.Error(w, "picoclaw integration is not configured", http.StatusServiceUnavailable) - return "", false + out := message + if len(message.Mentions) > 0 { + out.Mentions = append([]im.Mention(nil), message.Mentions...) + for idx := range out.Mentions { + if target.matches(out.Mentions[idx].ID) { + out.Mentions[idx].ID = target.bridgeID + } + } } - if h.im != nil { - botID = h.im.ResolveUserID(botID) + out.Content = contentForBotBridgeTarget(message.Content, target) + return out +} + +func contentForBotBridgeTarget(content string, target botBridgeTarget) string { + if content == "" { + return content } - if !h.validateServerAccessToken(r.Header.Get("Authorization")) { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return "", false + for _, alias := range target.aliases { + alias = strings.TrimSpace(alias) + if alias == "" || alias == target.bridgeID { + continue + } + content = strings.ReplaceAll(content, fmt.Sprintf(``, alias), fmt.Sprintf(``, target.bridgeID)) } - return botID, true + return content } func (h *Handler) handleBotEvents(w http.ResponseWriter, r *http.Request, botID string) { @@ -243,7 +363,7 @@ func (h *Handler) replayRecentBotMessages(botID, lastEventID string) { if h.isAgentSender(message.SenderID) { continue } - if hasLaterMessageFrom(room.Messages[idx+1:], botID) { + if h.hasLaterMessageFromBridgeTarget(room.Messages[idx+1:], botID) { continue } sender, ok := h.im.User(message.SenderID) @@ -252,7 +372,7 @@ func (h *Handler) replayRecentBotMessages(botID, lastEventID string) { } if reason, ok, err := newConversationCommandReason(message.Content); err != nil { slog.Warn("parse new conversation command failed", "bot_id", botID, "message_id", message.ID, "error", err) - h.botBridge.EnqueueMessageEvent(room, sender, message, botID) + h.enqueueBotMessageEventForBridgeID(room, sender, message, botID, "") continue } else if ok { missed := h.publishNewConversationBotEvent(context.Background(), room, sender, message, reason) @@ -261,9 +381,19 @@ func (h *Handler) replayRecentBotMessages(botID, lastEventID string) { } // Route replay through the bridge so the stable message ID remains the // dedupe key for events already delivered live or drained from pending. - h.botBridge.EnqueueMessageEvent(room, sender, message, botID) + h.enqueueBotMessageEventForBridgeID(room, sender, message, botID, "") + } + } +} + +func (h *Handler) hasLaterMessageFromBridgeTarget(messages []im.Message, bridgeID string) bool { + target := h.botBridgeTargetForBridgeID(bridgeID) + for _, message := range messages { + if target.matches(message.SenderID) { + return true } } + return false } func replayCursor(rooms []im.Room, lastEventID string) (time.Time, bool) { @@ -304,18 +434,18 @@ func (h *Handler) reconnectMissedBotAgents(senderID string, botIDs []string) { } seen := make(map[string]struct{}, len(botIDs)) for _, botID := range botIDs { - botID = strings.TrimSpace(botID) - if botID == "" { + agentID := h.runtimeAgentIDForBridgeID(botID) + if agentID == "" { continue } - if _, ok := seen[botID]; ok { + if _, ok := seen[agentID]; ok { continue } - seen[botID] = struct{}{} - if _, ok := h.svc.Agent(botID); !ok { + seen[agentID] = struct{}{} + if _, ok := h.svc.Agent(agentID); !ok { continue } - go h.recoverMissedBotDelivery(botID) + go h.recoverMissedBotDelivery(agentID) } } @@ -359,10 +489,28 @@ func (h *Handler) isAgentSender(senderID string) bool { if h == nil || h.svc == nil { return false } - _, ok := h.svc.Agent(senderID) + _, ok := h.svc.Agent(h.runtimeAgentIDForBridgeID(senderID)) return ok } +func (h *Handler) runtimeAgentIDForBridgeID(id string) string { + id = strings.TrimSpace(id) + if id == "" { + return "" + } + if id == agent.ManagerParticipantID { + return agent.ManagerUserID + } + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelCSGClaw, id); ok { + if agentID := strings.TrimSpace(item.AgentID); agentID != "" { + return agentID + } + } + } + return id +} + func hasLaterMessageFrom(messages []im.Message, senderID string) bool { senderID = strings.TrimSpace(senderID) if senderID == "" { @@ -405,25 +553,3 @@ func (h *Handler) handleBotSendMessage(w http.ResponseWriter, r *http.Request, b h.publishThreadUpdated(roomID, message) writeJSON(w, http.StatusOK, map[string]string{"message_id": message.ID}) } - -func parseBotCompatibilityPath(path string) (botID, action string, ok bool) { - const prefix = "/api/bots/" - if !strings.HasPrefix(path, prefix) { - return "", "", false - } - - rest := strings.TrimPrefix(path, prefix) - parts := strings.Split(strings.Trim(rest, "/"), "/") - if len(parts) < 2 || parts[0] == "" { - return "", "", false - } - - botID = parts[0] - action = strings.Join(parts[1:], "/") - switch action { - case "events", "messages/send", "llm/models", "llm/v1/models", "llm/chat/completions", "llm/v1/chat/completions", "llm/responses", "llm/v1/responses": - return botID, action, true - default: - return "", "", false - } -} diff --git a/internal/api/conversation_command.go b/internal/api/conversation_command.go index 4a1140fa..40c28baf 100644 --- a/internal/api/conversation_command.go +++ b/internal/api/conversation_command.go @@ -28,10 +28,12 @@ func (h *Handler) publishNewConversationBotEvent(ctx context.Context, room im.Ro } var missed []string threadRootID := conversationThreadRootID(message) - for _, botID := range newConversationTargets(room, message, h.isAgentSender) { + for _, target := range h.newConversationBridgeTargets(room, message) { + botID := target.bridgeID + agentID := h.runtimeAgentIDForBridgeID(botID) action, err := h.svc.NewConversationAction(ctx, agent.NewConversationRequest{ Channel: csgclawchannel.ChannelID, - BotID: botID, + BotID: agentID, RoomID: room.ID, ThreadRootID: threadRootID, Reason: reason, @@ -45,11 +47,11 @@ func (h *Handler) publishNewConversationBotEvent(ctx context.Context, room im.Ro if action.BotEventText == "" { continue } - if !h.botBridge.EnqueueMessageEventWithText(room, sender, message, botID, action.BotEventText) { + if !h.enqueueBotMessageEventForBridgeTarget(room, sender, message, target, action.BotEventText) { missed = append(missed, botID) } case agent.NewConversationActionInternal: - if !h.botBridge.EnqueueMessageEvent(room, sender, message, botID) { + if !h.enqueueBotMessageEventForBridgeTarget(room, sender, message, target, "") { missed = append(missed, botID) } } @@ -57,6 +59,32 @@ func (h *Handler) publishNewConversationBotEvent(ctx context.Context, room im.Ro return missed } +func (h *Handler) newConversationBridgeTargets(room im.Room, message im.Message) []botBridgeTarget { + if h == nil { + return nil + } + targets := make([]botBridgeTarget, 0) + for _, target := range h.botBridgeTargetsForRoom(room) { + if strings.TrimSpace(target.bridgeID) == "" || target.matches(message.SenderID) || !h.isAgentSender(target.bridgeID) { + continue + } + if !room.IsDirect && !messageMentionsBridgeTarget(message, target) { + continue + } + targets = append(targets, target) + } + return targets +} + +func messageMentionsBridgeTarget(message im.Message, target botBridgeTarget) bool { + for _, mention := range message.Mentions { + if target.matches(mention.ID) { + return true + } + } + return false +} + func newConversationTargets(room im.Room, message im.Message, isAgent func(string) bool) []string { if isAgent == nil { return nil diff --git a/internal/api/feishu.go b/internal/api/feishu.go index a61a4e9f..559a3d76 100644 --- a/internal/api/feishu.go +++ b/internal/api/feishu.go @@ -22,6 +22,14 @@ func (h *Handler) handleFeishuBotByID(w http.ResponseWriter, r *http.Request) { } func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, botID string) { + h.streamFeishuEvents(w, r, botID, true) +} + +func (h *Handler) handleFeishuParticipantEvents(w http.ResponseWriter, r *http.Request, targetID string) { + h.streamFeishuEvents(w, r, targetID, false) +} + +func (h *Handler) streamFeishuEvents(w http.ResponseWriter, r *http.Request, targetID string, resolveBotOpenID bool) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -34,10 +42,14 @@ func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, bot http.Error(w, "feishu events are not configured", http.StatusServiceUnavailable) return } - botOpenID, _, err := h.feishu.ResolveBotOpenID(r.Context(), botID) - if err != nil { - http.Error(w, fmt.Sprintf("resolve feishu bot open_id: %v", err), http.StatusBadRequest) - return + targetID = strings.TrimSpace(targetID) + if resolveBotOpenID { + botOpenID, _, err := h.feishu.ResolveBotOpenID(r.Context(), targetID) + if err != nil { + http.Error(w, fmt.Sprintf("resolve feishu bot open_id: %v", err), http.StatusBadRequest) + return + } + targetID = strings.TrimSpace(botOpenID) } flusher, ok := w.(http.Flusher) @@ -72,7 +84,7 @@ func (h *Handler) handleFeishuEvents(w http.ResponseWriter, r *http.Request, bot if !ok { return } - if !feishuEventMentions(evt, botOpenID) { + if !feishuEventMentions(evt, targetID) { continue } data, err := json.Marshal(evt) diff --git a/internal/api/handler.go b/internal/api/handler.go index 80178863..fab6a5df 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -23,6 +23,7 @@ import ( "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" "csgclaw/internal/sandbox" "csgclaw/internal/sandboxproviders" "csgclaw/internal/team" @@ -34,6 +35,7 @@ import ( type Handler struct { svc *agent.Service botSvc *bot.Service + participant *participant.Service im *im.Service csgclaw *csgclawchannel.Service imBus *im.Bus @@ -117,6 +119,7 @@ type agentResponse struct { AgentProfile agent.AgentProfileView `json:"agent_profile,omitempty"` ProfileComplete bool `json:"profile_complete"` DetectionResults []agent.ProfileDetectionResult `json:"detection_results,omitempty"` + Participants []apitypes.Participant `json:"participants,omitempty"` } func (h *Handler) handleBootstrapConfig(w http.ResponseWriter, r *http.Request) { @@ -344,6 +347,12 @@ func (h *Handler) SetNotificationDeliver(d notification_bot.Fanouter) { } } +func (h *Handler) SetParticipantService(svc *participant.Service) { + if h != nil { + h.participant = svc + } +} + func (h *Handler) SetActivityDecider(decider ActivityDecider) { if h != nil { h.activityDecider = decider @@ -482,134 +491,6 @@ func (h *Handler) handleUpgradeApply(w http.ResponseWriter, r *http.Request) { }) } -func (h *Handler) handleBots(w http.ResponseWriter, r *http.Request) { - if h.botSvc == nil { - http.Error(w, "bot service is not configured", http.StatusServiceUnavailable) - return - } - h.botSvc.SetIMBus(h.imBus) - channelName := botChannelName(r) - - switch r.Method { - case http.MethodGet: - bots, err := h.botSvc.List(channelName, r.URL.Query().Get("role"), r.URL.Query().Get("type")) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusOK, bots) - case http.MethodPost: - var req apitypes.CreateBotRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) - return - } - req.Channel = channelName - req.Type = bot.NormalizeBotType(req.Type) - if req.Type == bot.BotTypeNotification { - created, err := h.botSvc.CreateNotificationBot(r.Context(), req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusCreated, created) - return - } - created, err := h.botSvc.Create(r.Context(), req) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - writeJSON(w, http.StatusCreated, created) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func (h *Handler) handleBotByID(w http.ResponseWriter, r *http.Request) { - if h.botSvc == nil { - http.Error(w, "bot service is not configured", http.StatusServiceUnavailable) - return - } - - id := pathValue(r, "id") - if id == "" { - http.NotFound(w, r) - return - } - channelName := botChannelName(r) - - stored, found, err := h.botSvc.BotByChannelID(channelName, id) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if !found { - http.NotFound(w, r) - return - } - - switch r.Method { - case http.MethodGet, http.MethodPatch: - if !bot.IsNotificationBot(stored) { - http.Error(w, "method not allowed for this bot type", http.StatusMethodNotAllowed) - return - } - } - switch r.Method { - case http.MethodGet: - b, err := h.botSvc.GetNotificationBot(channelName, id) - if err != nil { - http.Error(w, err.Error(), http.StatusNotFound) - return - } - writeJSON(w, http.StatusOK, b) - case http.MethodPatch: - var patch apitypes.PatchNotificationBotRequest - if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { - http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) - return - } - updated, err := h.botSvc.PatchNotificationBot(r.Context(), channelName, id, bot.CreateRequest{ - Name: patch.Name, - Description: patch.Description, - Avatar: patch.Avatar, - RuntimeOptions: patch.RuntimeOptions, - }) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - h.publishUpdatedBotUser(updated) - writeJSON(w, http.StatusOK, updated) - case http.MethodDelete: - if err := h.botSvc.Delete(r.Context(), channelName, id); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.WriteHeader(http.StatusNoContent) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } -} - -func botChannelName(r *http.Request) string { - if channel := pathValue(r, "channel"); channel != "" { - return channel - } - if r == nil { - return "" - } - switch { - case strings.HasPrefix(r.URL.Path, "/api/v1/channels/csgclaw/"): - return "csgclaw" - case strings.HasPrefix(r.URL.Path, "/api/v1/channels/feishu/"): - return "feishu" - default: - return "" - } -} - func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { if h.svc == nil { http.Error(w, "agent service is not configured", http.StatusServiceUnavailable) @@ -621,7 +502,7 @@ func (h *Handler) handleAgents(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - writeJSON(w, http.StatusOK, presentAgents(h.svc.List())) + writeJSON(w, http.StatusOK, h.presentAgentsForRequest(r, h.svc.List())) case http.MethodPost: h.handleCreateAgentWorker(w, r) default: @@ -652,7 +533,7 @@ func (h *Handler) handleAgentByID(w http.ResponseWriter, r *http.Request) { http.Error(w, "agent not found", http.StatusNotFound) return } - writeJSON(w, http.StatusOK, presentAgent(a)) + writeJSON(w, http.StatusOK, h.presentAgentForRequest(r, a)) case http.MethodPatch: var req agent.UpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -707,32 +588,6 @@ func (h *Handler) publishUpdatedAgentUser(updated agent.Agent) { } } -func (h *Handler) publishUpdatedBotUser(updated bot.Bot) { - if h == nil || h.im == nil { - return - } - id := strings.TrimSpace(updated.UserID) - if id == "" { - id = strings.TrimSpace(updated.ID) - } - user, ok, err := h.im.UpdateAgentUser(im.UpdateAgentUserRequest{ - ID: id, - Name: updated.Name, - Role: updated.Role, - Avatar: updated.Avatar, - }) - if err != nil || !ok { - return - } - if h.imBus != nil { - userCopy := user - h.imBus.Publish(im.Event{ - Type: im.EventTypeUserUpdated, - User: &userCopy, - }) - } -} - func (h *Handler) handleAgentProfileByID(w http.ResponseWriter, r *http.Request) { id := pathValue(r, "id") if id == "" { @@ -1576,6 +1431,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(req.Name) handle := strings.TrimSpace(req.Handle) role := strings.TrimSpace(req.Role) + id = h.resolveCSGClawParticipantUserID(id) if id == "" { http.Error(w, "id is required", http.StatusBadRequest) @@ -1589,20 +1445,36 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { handle = name } - if h.botSvc != nil && h.svc != nil && shouldCreateWorkerForUser(id, role) { - h.botSvc.SetDependencies(h.svc, h.im, h.feishu) - h.botSvc.SetIMBus(h.imBus) - created, err := h.botSvc.Create(r.Context(), apitypes.CreateBotRequest{ - ID: id, + if h.participant != nil && h.svc != nil && shouldCreateWorkerForUser(id, role) { + participantID := workerParticipantIDFromUserID(id) + created, err := h.participant.Create(r.Context(), participant.CreateRequest{ + ID: participantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, Name: name, - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), + ChannelUser: participant.ChannelUserSpec{ + Ref: id, + Kind: participant.ChannelUserKindLocalUserID, + }, + AgentBinding: participant.AgentBindingSpec{ + Mode: participant.BindingModeCreate, + AgentID: id, + Agent: &agent.CreateAgentSpec{ + ID: id, + Name: name, + Role: agent.RoleWorker, + }, + }, }) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if user, ok := h.im.User(created.UserID); ok { + if user, ok := h.im.User(created.ChannelUserRef); ok { + h.publishUserEvent(im.EventTypeUserCreated, user) + if room, ok := h.directRoomWithMembers("u-admin", user.ID); ok { + h.publishRoomEvent(im.EventTypeRoomCreated, room) + } writeJSON(w, http.StatusCreated, user) return } @@ -1641,6 +1513,42 @@ func shouldCreateWorkerForUser(id, role string) bool { } } +func workerParticipantIDFromUserID(id string) string { + id = strings.TrimSpace(id) + withoutPrefix := strings.TrimPrefix(id, "u-") + if withoutPrefix != "" && withoutPrefix != id { + return withoutPrefix + } + return id +} + +func (h *Handler) directRoomWithMembers(left, right string) (im.Room, bool) { + if h == nil || h.im == nil { + return im.Room{}, false + } + left = strings.TrimSpace(left) + right = strings.TrimSpace(right) + for _, room := range h.im.ListRooms() { + if !room.IsDirect { + continue + } + if roomHasMember(room.Members, left) && roomHasMember(room.Members, right) { + return room, true + } + } + return im.Room{}, false +} + +func roomHasMember(members []string, id string) bool { + id = strings.TrimSpace(id) + for _, member := range members { + if strings.TrimSpace(member) == id { + return true + } + } + return false +} + func (h *Handler) handleCreateMessage(w http.ResponseWriter, r *http.Request) { channel, ok := h.requireLocalChannel(w) if !ok { @@ -1657,6 +1565,7 @@ func (h *Handler) handleCreateMessage(w http.ResponseWriter, r *http.Request) { http.Error(w, err.Error(), http.StatusBadRequest) return } + serviceReq = h.resolveCSGClawParticipantMessageRequest(serviceReq) message, err := channel.SendMessage(serviceReq) if err != nil { @@ -1679,6 +1588,8 @@ func (h *Handler) handleCreateRoom(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) return } + req.CreatorID = h.resolveCSGClawParticipantUserID(req.CreatorID) + req.MemberIDs = h.resolveCSGClawParticipantUserIDs(req.MemberIDs) room, err := channel.CreateRoom(req) if err != nil { @@ -1738,6 +1649,8 @@ func (h *Handler) handleAddRoomMembers(w http.ResponseWriter, r *http.Request, p http.Error(w, err.Error(), http.StatusBadRequest) return } + serviceReq.InviterID = h.resolveCSGClawParticipantUserID(serviceReq.InviterID) + serviceReq.UserIDs = h.resolveCSGClawParticipantUserIDs(serviceReq.UserIDs) room, err := channel.AddRoomMembers(serviceReq) if err != nil { @@ -1885,6 +1798,93 @@ func presentAgents(items []agent.Agent) []agentResponse { return out } +func (h *Handler) presentAgentsForRequest(r *http.Request, items []agent.Agent) []agentResponse { + out := presentAgents(items) + if !includeParticipants(r) || h == nil || h.participant == nil { + return out + } + byAgent := participantsByAgentID(h.participant.List(participant.ListOptions{})) + for i := range out { + out[i].Participants = byAgent[out[i].ID] + } + return out +} + +func (h *Handler) presentAgentForRequest(r *http.Request, item agent.Agent) agentResponse { + resp := presentAgent(item) + if !includeParticipants(r) || h == nil || h.participant == nil { + return resp + } + resp.Participants = h.participant.List(participant.ListOptions{AgentID: item.ID}) + return resp +} + +func includeParticipants(r *http.Request) bool { + if r == nil { + return false + } + return strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include_participants")), "true") +} + +func participantsByAgentID(items []apitypes.Participant) map[string][]apitypes.Participant { + out := make(map[string][]apitypes.Participant) + for _, item := range items { + if strings.TrimSpace(item.AgentID) == "" { + continue + } + out[item.AgentID] = append(out[item.AgentID], item) + } + return out +} + +func (h *Handler) resolveCSGClawParticipantMessageRequest(req im.CreateMessageRequest) im.CreateMessageRequest { + req.SenderID = h.resolveCSGClawParticipantUserID(req.SenderID) + req.MentionID = h.resolveCSGClawParticipantUserID(req.MentionID) + return req +} + +func (h *Handler) resolveCSGClawParticipantUserIDs(ids []string) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if resolved := h.resolveCSGClawParticipantUserID(id); resolved != "" { + out = append(out, resolved) + } + } + return out +} + +func (h *Handler) resolveCSGClawParticipantUserID(id string) string { + id = strings.TrimSpace(id) + if id == "" || h == nil || h.participant == nil { + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id + } + item, ok := h.participant.Get(participant.ChannelCSGClaw, id) + if ok { + if ref := strings.TrimSpace(item.ChannelUserRef); ref != "" { + return ref + } + return id + } + for _, candidate := range h.participant.List(participant.ListOptions{Channel: participant.ChannelCSGClaw, AgentID: id}) { + if ref := strings.TrimSpace(candidate.ChannelUserRef); ref != "" { + return ref + } + if participantID := strings.TrimSpace(candidate.ID); participantID != "" { + return participantID + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + func presentAgent(item agent.Agent) agentResponse { av := agent.RedactedProfileViewForAgent(item) if strings.TrimSpace(av.Name) == strings.TrimSpace(item.Name) { diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index f87fce1a..e2fb5d40 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -23,6 +23,7 @@ import ( "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" agentruntime "csgclaw/internal/runtime" "csgclaw/internal/runtime/openclawsandbox" "csgclaw/internal/runtime/picoclawsandbox" @@ -160,35 +161,6 @@ func (f *fakeCodexBridgeController) StopAgent(agentID string) { f.stopCalls = append(f.stopCalls, agentID) } -func TestParseBotCompatibilityPath(t *testing.T) { - tests := []struct { - path string - wantBotID string - wantAction string - wantOK bool - }{ - {path: "/api/bots/u-manager/events", wantBotID: "u-manager", wantAction: "events", wantOK: true}, - {path: "/api/bots/u-manager/messages/send", wantBotID: "u-manager", wantAction: "messages/send", wantOK: true}, - {path: "/api/bots/u-manager/llm/models", wantBotID: "u-manager", wantAction: "llm/models", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/models", wantBotID: "u-manager", wantAction: "llm/v1/models", wantOK: true}, - {path: "/api/bots/u-manager/llm/chat/completions", wantBotID: "u-manager", wantAction: "llm/chat/completions", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/chat/completions", wantBotID: "u-manager", wantAction: "llm/v1/chat/completions", wantOK: true}, - {path: "/api/bots/u-manager/llm/responses", wantBotID: "u-manager", wantAction: "llm/responses", wantOK: true}, - {path: "/api/bots/u-manager/llm/v1/responses", wantBotID: "u-manager", wantAction: "llm/v1/responses", wantOK: true}, - {path: "/api/bots/u-manager", wantOK: false}, - {path: "/api/bots//events", wantOK: false}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - gotBotID, gotAction, gotOK := parseBotCompatibilityPath(tt.path) - if gotBotID != tt.wantBotID || gotAction != tt.wantAction || gotOK != tt.wantOK { - t.Fatalf("parseBotCompatibilityPath(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.path, gotBotID, gotAction, gotOK, tt.wantBotID, tt.wantAction, tt.wantOK) - } - }) - } -} - func TestDeriveAgentHandle(t *testing.T) { tests := []struct { name string @@ -295,73 +267,6 @@ func TestBootstrapConfigViewUsesServerUpgradeVisibility(t *testing.T) { } } -func TestHandleAgentImageCandidatesReturnsLocalSandboxImages(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "config.toml") - if err := (config.Config{ - Server: config.ServerConfig{ - ListenAddr: "127.0.0.1:18080", - AccessToken: "token", - }, - Sandbox: config.SandboxConfig{ - Provider: config.DockerProvider, - DockerCLIPath: "/custom/docker", - }, - }).Save(configPath); err != nil { - t.Fatalf("Save(config) error = %v", err) - } - - srv := &Handler{ - configPath: configPath, - localRuntimeImages: func(_ context.Context, cfg config.Config) ([]string, error) { - if got, want := cfg.Sandbox.Provider, config.DockerProvider; got != want { - t.Fatalf("cfg.Sandbox.Provider = %q, want %q", got, want) - } - if got, want := cfg.Sandbox.DockerCLIPath, "/custom/docker"; got != want { - t.Fatalf("cfg.Sandbox.DockerCLIPath = %q, want %q", got, want) - } - return []string{"registry.example/picoclaw:2026.5.27", "registry.example/picoclaw:2026.5.22"}, nil - }, - } - - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/agents/image-candidates", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []string - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - want := []string{"registry.example/picoclaw:2026.5.27", "registry.example/picoclaw:2026.5.22"} - if !slices.Equal(got, want) { - t.Fatalf("agent image candidates = %#v, want %#v", got, want) - } -} - -func TestListLocalRuntimeImagesUsesSandboxProviderCapability(t *testing.T) { - var gotHome string - provider := &sandboxtest.Provider{ - Images: []string{"registry.example/picoclaw:2026.5.27"}, - ListImagesFunc: func(_ context.Context, homeDir string) ([]string, error) { - gotHome = homeDir - return []string{"registry.example/picoclaw:2026.5.27"}, nil - }, - } - - got, err := listLocalRuntimeImagesWithProvider(context.Background(), provider) - if err != nil { - t.Fatalf("listLocalRuntimeImagesWithProvider() error = %v", err) - } - want := []string{"registry.example/picoclaw:2026.5.27"} - if !slices.Equal(got, want) { - t.Fatalf("listLocalRuntimeImagesWithProvider() = %#v, want %#v", got, want) - } - wantHomeSuffix := filepath.Join(config.AppDirName, "agents", agent.ManagerName, config.RuntimeHomeDirName) - if !strings.HasSuffix(gotHome, wantHomeSuffix) { - t.Fatalf("sandbox home = %q, want suffix %q", gotHome, wantHomeSuffix) - } -} - func TestHandleFeishuRoomsMembers(t *testing.T) { feishuSvc := feishu.NewServiceWithCreateChatAndAddMembers( map[string]feishu.AppConfig{ @@ -395,715 +300,95 @@ func TestHandleFeishuRoomsMembers(t *testing.T) { } srv := &Handler{feishu: feishuSvc} - createReq := strings.NewReader(`{"title":"alpha","creator_id":"fsu-admin"}`) - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms", createReq)) - if rec.Code != http.StatusCreated { - t.Fatalf("create status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var room im.Room - if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { - t.Fatalf("decode room: %v", err) - } - - addReq := strings.NewReader(`{"user_ids":["fsu-alice"]}`) - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", addReq)) - if rec.Code != http.StatusOK { - t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var members []im.User - if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { - t.Fatalf("decode members: %v", err) - } - if len(members) != 2 { - t.Fatalf("members = %+v, want two users", members) - } - if members[0].ID != "u-manager" || members[1].ID != "fsu-alice" { - t.Fatalf("members = %+v, want bot ids", members) - } -} - -func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) { - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, - {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, - }, - Rooms: []im.Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-alice"}}, - }, - }) - srv := &Handler{im: imSvc} - - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/members", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var members []im.User - if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { - t.Fatalf("decode members: %v", err) - } - if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { - t.Fatalf("members = %+v, want room members", members) - } -} - -func TestHandleRoomsMembersAddsCsgclawMember(t *testing.T) { - imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-admin", - Users: []im.User{ - {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, - {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, - }, - Rooms: []im.Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin"}}, - }, - }) - srv := &Handler{im: imSvc} - - rec := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/members", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-alice"]}`)) - srv.Routes().ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - - var room im.Room - if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { - t.Fatalf("decode room: %v", err) - } - if len(room.Members) != 2 || room.Members[1] != "u-alice" { - t.Fatalf("members = %+v, want u-admin and u-alice", room.Members) - } -} - -func TestHandleBotsListUsesChannelPath(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "agent-csgclaw", - UserID: "user-csgclaw", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(bot.RoleManager), - Channel: string(bot.ChannelFeishu), - AgentID: "agent-feishu", - UserID: "user-feishu", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-csgclaw" { - t.Fatalf("bots = %+v, want only csgclaw bot", got) - } -} - -func TestHandleBotsListFiltersByChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-csgclaw", - Name: "CSGClaw Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-feishu", - Name: "Feishu Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelFeishu), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-csgclaw" { - t.Fatalf("bots = %+v, want only bot-csgclaw", got) - } -} - -func TestHandleBotsListFiltersByRole(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "bot-manager", - Name: "Manager Bot", - Role: string(bot.RoleManager), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - { - ID: "bot-worker", - Name: "Worker Bot", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), - }, - })} - - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots?role=worker", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var got []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { - t.Fatalf("decode response: %v", err) - } - if len(got) != 1 || got[0].ID != "bot-worker" { - t.Fatalf("bots = %+v, want only bot-worker", got) - } -} - -func TestHandleBotsListRejectsInvalidChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/unknown/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) - } -} - -func TestHandleBotsListRejectsInvalidRole(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots?role=agent", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) - } -} - -func TestHandleBotsListRequiresService(t *testing.T) { - srv := &Handler{} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusServiceUnavailable, rec.Body.String()) - } -} - -func TestHandleBotsCreateCSGClawWorker(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - imSvc := im.NewService() - bus := im.NewBus() - events, cancel := bus.Subscribe() - defer cancel() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","description":"test lead","image":"agent-image:1","avatar":"avatar/cartoon-3.png","role":"worker","runtime_kind":"picoclaw_sandbox","agent_profile":{"provider":"csghub_lite","model_id":"glm-4.5","reasoning_effort":"high"}}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != "u-alice" || created.AgentID != "u-alice" || created.UserID != "u-alice" { - t.Fatalf("created bot = %+v, want u-alice IDs", created) - } - if created.Description != "test lead" { - t.Fatalf("created bot description = %q, want test lead", created.Description) - } - if created.Avatar != "avatar/cartoon-3.png" { - t.Fatalf("created bot avatar = %q, want avatar/cartoon-3.png", created.Avatar) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list bots status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var bots []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&bots); err != nil { - t.Fatalf("decode bots response: %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("bots = %+v, want u-alice", bots) - } - if bots[0].Description != "test lead" { - t.Fatalf("bots[0].Description = %q, want test lead", bots[0].Description) - } - if bots[0].Avatar != "avatar/cartoon-3.png" { - t.Fatalf("bots[0].Avatar = %q, want avatar/cartoon-3.png", bots[0].Avatar) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list agents status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var agents []map[string]any - if err := json.NewDecoder(rec.Body).Decode(&agents); err != nil { - t.Fatalf("decode agents response: %v", err) - } - if len(agents) != 1 || agents[0]["id"] != "u-alice" { - t.Fatalf("agents = %+v, want u-alice", agents) - } - if agents[0]["image"] != "agent-image:1" { - t.Fatalf("agents[0].image = %#v, want agent-image:1", agents[0]["image"]) - } - if agents[0]["avatar"] != "avatar/cartoon-3.png" { - t.Fatalf("agents[0].avatar = %#v, want avatar/cartoon-3.png", agents[0]["avatar"]) - } - profile, ok := agents[0]["agent_profile"].(map[string]any) - if !ok || profile["provider"] != agent.ProviderCSGHubLite || profile["model_id"] != "glm-4.5" { - t.Fatalf("agent_profile = %#v, want csghub_lite/glm-4.5", agents[0]["agent_profile"]) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list users status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var users []im.User - if err := json.NewDecoder(rec.Body).Decode(&users); err != nil { - t.Fatalf("decode users response: %v", err) - } - if !containsUser(users, "u-alice") { - t.Fatalf("users = %+v, want u-alice", users) - } - for _, user := range users { - if user.ID == "u-alice" && user.Avatar != "avatar/cartoon-3.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-3.png", user.Avatar) - } - } - rooms := imSvc.ListRooms() - if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") { - t.Fatalf("rooms = %+v, want bootstrap room with admin and u-alice", rooms) - } - first := mustReceiveIMEvent(t, events) - if first.Type != im.EventTypeUserCreated || first.User == nil || first.User.ID != "u-alice" { - t.Fatalf("first event = %+v, want user_created for u-alice", first) - } - second := mustReceiveIMEvent(t, events) - if second.Type != im.EventTypeRoomCreated || second.Room == nil { - t.Fatalf("second event = %+v, want room_created with room payload", second) - } - third := mustReceiveIMEventWithin(t, events, 2*time.Second) - if third.Type != im.EventTypeMessageCreated || third.Message == nil { - t.Fatalf("third event = %+v, want bootstrap message", third) - } -} - -func TestHandleBotsCreateCodexWorkerEnsuresCodexBridge(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, err := agent.NewService( - config.ModelConfig{ - Provider: config.ProviderLLMAPI, - BaseURL: "http://127.0.0.1:4000", - APIKey: "sk-test", - ModelID: "model-1", - }, - config.ServerConfig{}, "manager-image:test", "", - agent.WithRuntime(fakeCompatRuntime{ - kind: agent.RuntimeKindCodex, - new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { - return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "codex-" + spec.AgentName}, nil - }, - }), - ) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - - imSvc := im.NewService() - bus := im.NewBus() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - bridge := &fakeCodexBridgeController{} - agentSvc.SetLifecycleObserver(bridge) - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","role":"worker","runtime_kind":"codex"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - if len(bridge.ensureCalls) != 1 { - t.Fatalf("EnsureAgent() calls = %d, want 1", len(bridge.ensureCalls)) - } - if bridge.ensureCalls[0].ID != "u-alice" || bridge.ensureCalls[0].RuntimeKind != agent.RuntimeKindCodex { - t.Fatalf("EnsureAgent() got %+v, want codex worker u-alice", bridge.ensureCalls[0]) - } -} - -func TestHandleBotsCreateFeishuWorker(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - feishuSvc := feishu.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, nil, feishuSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - feishu: feishuSvc, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != "u-alice" || created.AgentID != "u-alice" || created.UserID != "u-alice" || created.Channel != "feishu" { - t.Fatalf("created bot = %+v, want feishu u-alice IDs", created) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list bots status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var bots []bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&bots); err != nil { - t.Fatalf("decode bots response: %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("bots = %+v, want u-alice", bots) - } - - rec = httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/users", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("list feishu users status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) - } - var users []im.User - if err := json.NewDecoder(rec.Body).Decode(&users); err != nil { - t.Fatalf("decode users response: %v", err) - } - if !containsUser(users, "u-alice") { - t.Fatalf("feishu users = %+v, want u-alice", users) - } -} - -func TestHandleBotsCreateRejectsDuplicateWorkerNameInSameChannel(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc, _ := mustNewSeededServiceWithPath(t, nil) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - } - - first := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - firstRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(firstRec, first) - if firstRec.Code != http.StatusCreated { - t.Fatalf("first status = %d, want %d; body=%s", firstRec.Code, http.StatusCreated, firstRec.Body.String()) - } - - second := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"alice","image":"agent-image:1","role":"worker","runtime_kind":"picoclaw_sandbox"}`)) - secondRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(secondRec, second) - if secondRec.Code != http.StatusBadRequest { - t.Fatalf("second status = %d, want %d; body=%s", secondRec.Code, http.StatusBadRequest, secondRec.Body.String()) - } - if !strings.Contains(secondRec.Body.String(), `bot name "alice" already exists in channel "csgclaw"`) { - t.Fatalf("second body = %q, want duplicate name error", secondRec.Body.String()) - } -} - -func TestHandleBotsCreateCSGClawManagerBindsBootstrappedAgent(t *testing.T) { - agentSvc := mustNewSeededService(t, []agent.Agent{ - { - ID: agent.ManagerUserID, - Name: agent.ManagerName, - Role: agent.RoleManager, - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"manager","role":"manager"}`)) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) - } - if created.ID != agent.ManagerUserID || created.AgentID != agent.ManagerUserID || created.UserID != agent.ManagerUserID || created.Role != string(bot.RoleManager) { - t.Fatalf("created bot = %+v, want manager u-manager IDs", created) - } -} - -func TestHandleBotsCreateManagerBootstrapsMissingAgent(t *testing.T) { - t.Setenv("HOME", t.TempDir()) - t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) - - agentSvc := mustNewSeededService(t, nil) - imSvc := im.NewService() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - } - - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{"name":"manager","role":"manager"}`)) + createReq := strings.NewReader(`{"title":"alpha","creator_id":"fsu-admin"}`) rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms", createReq)) if rec.Code != http.StatusCreated { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) - } - var created bot.Bot - if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { - t.Fatalf("decode response: %v", err) + t.Fatalf("create status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) } - if created.ID != agent.ManagerUserID || created.AgentID != agent.ManagerUserID || created.UserID != agent.ManagerUserID { - t.Fatalf("created bot = %+v, want u-manager IDs", created) + var room im.Room + if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { + t.Fatalf("decode room: %v", err) } -} - -func TestHandleBotsListRejectsUnsupportedMethod(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, nil)} - req := httptest.NewRequest(http.MethodPut, "/api/v1/channels/csgclaw/bots", strings.NewReader(`{}`)) - rec := httptest.NewRecorder() - srv.Routes().ServeHTTP(rec, req) + addReq := strings.NewReader(`{"user_ids":["fsu-alice"]}`) + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", addReq)) + if rec.Code != http.StatusOK { + t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusMethodNotAllowed, rec.Body.String()) + rec = httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/rooms/"+room.ID+"/members", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var members []im.User + if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { + t.Fatalf("decode members: %v", err) + } + if len(members) != 2 { + t.Fatalf("members = %+v, want two users", members) + } + if members[0].ID != "u-manager" || members[1].ID != "fsu-alice" { + t.Fatalf("members = %+v, want bot ids", members) } } -func TestHandleBotByIDDeleteUsesChannel(t *testing.T) { - srv := &Handler{botSvc: mustNewBotService(t, []bot.Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), +func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, + {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, }, - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelFeishu), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), + Rooms: []im.Room{ + {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-alice"}}, }, - })} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/feishu/bots/u-alice", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) + }) + srv := &Handler{im: imSvc} - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - bots, err := srv.botSvc.List(string(bot.ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-alice" { - t.Fatalf("csgclaw bots = %+v, want retained u-alice", bots) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/rooms/room-1/members", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("members status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - bots, err = srv.botSvc.List(string(bot.ChannelFeishu), "", "") - if err != nil { - t.Fatalf("List(feishu) error = %v", err) + + var members []im.User + if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { + t.Fatalf("decode members: %v", err) } - if len(bots) != 0 { - t.Fatalf("feishu bots = %+v, want deleted", bots) + if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { + t.Fatalf("members = %+v, want room members", members) } } -func TestHandleBotByIDDeleteRemovesCSGClawUser(t *testing.T) { - store, err := bot.NewMemoryStore([]bot.Bot{ - { - ID: "u-alice", - Name: "Alice", - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-alice", - UserID: "u-alice", - CreatedAt: time.Date(2026, 4, 12, 9, 0, 0, 0, time.UTC), - }, - }) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } +func TestHandleRoomsMembersAddsCsgclawMember(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ CurrentUserID: "u-admin", Users: []im.User{ - {ID: "u-admin", Name: "admin", Handle: "admin", IsOnline: true}, - {ID: "u-alice", Name: "Alice", Handle: "alice", IsOnline: true}, + {ID: "u-admin", Name: "Admin", Handle: "admin", Role: "admin"}, + {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, }, Rooms: []im.Room{ - { - ID: "room-1", - Title: "Alice", - Members: []string{"u-admin", "u-alice"}, - Messages: []im.Message{{ID: "msg-1", SenderID: "u-alice", Content: "hello"}}, - }, + {ID: "room-1", Title: "Ops", Members: []string{"u-admin"}}, }, }) - botSvc, err := bot.NewServiceWithDependencies(store, nil, imSvc) - if err != nil { - t.Fatalf("NewServiceWithDependencies() error = %v", err) - } - srv := &Handler{botSvc: botSvc} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/bots/u-alice", nil) - rec := httptest.NewRecorder() + srv := &Handler{im: imSvc} + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/room-1/members", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-alice"]}`)) srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - if _, ok := imSvc.User("u-alice"); ok { - t.Fatal("User(u-alice) ok = true, want false after bot delete") - } - if _, ok := imSvc.Room("room-1"); ok { - t.Fatal("Room(room-1) ok = true, want false after removing DM user") + if rec.Code != http.StatusOK { + t.Fatalf("add status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } - bots, err := botSvc.List(string(bot.ChannelCSGClaw), "", "") - if err != nil { - t.Fatalf("List(csgclaw) error = %v", err) + + var room im.Room + if err := json.NewDecoder(rec.Body).Decode(&room); err != nil { + t.Fatalf("decode room: %v", err) } - if len(bots) != 0 { - t.Fatalf("csgclaw bots = %+v, want deleted", bots) + if len(room.Members) != 2 || room.Members[1] != "u-alice" { + t.Fatalf("members = %+v, want u-admin and u-alice", room.Members) } } @@ -1339,22 +624,9 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) { CreatedAt: time.Date(2026, 3, 28, 10, 0, 0, 0, time.UTC), }, }) - imSvc := im.NewService() - if _, _, err := imSvc.EnsureAgentUser(im.EnsureAgentUserRequest{ - ID: "u-alice", - Name: "alice", - Handle: "alice", - Role: agent.RoleWorker, - Avatar: "avatar/3D-1.png", - }); err != nil { - t.Fatalf("EnsureAgentUser() error = %v", err) - } - bus := im.NewBus() - events, cancel := bus.Subscribe() - defer cancel() - srv := &Handler{svc: svc, im: imSvc, imBus: bus} - body := `{"description":"new role","avatar":"avatar/cartoon-4.png","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}` + srv := &Handler{svc: svc} + body := `{"description":"new role","agent_profile":{"name":"alice","provider":"csghub_lite","model_id":"new-model","env":{"A":"B"}}}` req := httptest.NewRequest(http.MethodPatch, "/api/v1/agents/u-alice", strings.NewReader(body)) rec := httptest.NewRecorder() @@ -1370,24 +642,10 @@ func TestHandleAgentsPatchUpdatesMetadataAndProfile(t *testing.T) { if got["description"] != "new role" { t.Fatalf("agent = %#v, want updated description", got) } - if got["avatar"] != "avatar/cartoon-4.png" { - t.Fatalf("agent avatar = %#v, want updated avatar", got["avatar"]) - } profile, ok := got["agent_profile"].(map[string]any) if !ok || profile["env_restart_required"] != true || profile["model_id"] != "new-model" { t.Fatalf("agent_profile = %#v, want env_restart_required true", got["agent_profile"]) } - user, ok := imSvc.User("u-alice") - if !ok { - t.Fatal("User(u-alice) ok = false, want true") - } - if user.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar) - } - evt := mustReceiveIMEvent(t, events) - if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("event = %+v, want user.updated with updated avatar", evt) - } } func TestHandleAgentsGetByIDReloadsStateBeforeLookup(t *testing.T) { @@ -2003,7 +1261,6 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) { Name: "alice", RuntimeKind: agent.RuntimeKindCodex, FromTemplate: "builtin.frontend-alice", - Avatar: "avatar/3D-1.png", Profile: "codex-fast", }) @@ -2016,9 +1273,6 @@ func TestAgentCreateRequestFromAPIIncludesFromTemplate(t *testing.T) { if got.Spec.FromTemplate != "builtin.frontend-alice" { t.Fatalf("Spec.FromTemplate = %q, want %q", got.Spec.FromTemplate, "builtin.frontend-alice") } - if got.Spec.Avatar != "avatar/3D-1.png" { - t.Fatalf("Spec.Avatar = %q, want %q", got.Spec.Avatar, "avatar/3D-1.png") - } if got.Spec.Profile != "codex-fast" { t.Fatalf("Spec.Profile = %q, want %q", got.Spec.Profile, "codex-fast") } @@ -2053,62 +1307,6 @@ func TestHandleHubTemplatesListsAggregatedTemplates(t *testing.T) { } } -func TestHandleHubTemplateDeleteRemovesLocalTemplate(t *testing.T) { - hubSvc := mustNewLocalTemplateHubService(t, "review-bot", hub.Template{ - ID: "review-bot", - Name: "review-bot", - Role: hub.TemplateRoleWorker, - RuntimeKind: agent.RuntimeKindCodex, - }) - srv := &Handler{} - srv.SetHubService(hubSvc) - req := httptest.NewRequest(http.MethodDelete, "/api/v1/hub/templates/local.review-bot", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) - } - listReq := httptest.NewRequest(http.MethodGet, "/api/v1/hub/templates", nil) - listRec := httptest.NewRecorder() - srv.Routes().ServeHTTP(listRec, listReq) - if listRec.Code != http.StatusOK { - t.Fatalf("list status = %d, want %d; body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) - } - var listed []apitypes.HubTemplate - if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil { - t.Fatalf("decode list response: %v", err) - } - for _, item := range listed { - if item.ID == "local.review-bot" { - t.Fatalf("listed templates = %#v, want deleted local.review-bot", listed) - } - } -} - -func TestHandleHubTemplateDeleteRejectsBuiltinTemplate(t *testing.T) { - builtinSvc, err := hub.NewService(config.HubConfig{ - DefaultRegistry: "builtin", - Registries: []config.HubRegistryConfig{ - {Name: "builtin", Kind: hub.RegistryKindBuiltin, Enabled: true}, - }, - }, hub.DefaultStoreFactory) - if err != nil { - t.Fatalf("hub.NewService() error = %v", err) - } - srv := &Handler{} - srv.SetHubService(builtinSvc) - req := httptest.NewRequest(http.MethodDelete, "/api/v1/hub/templates/builtin.picoclaw-worker", nil) - rec := httptest.NewRecorder() - - srv.Routes().ServeHTTP(rec, req) - - if rec.Code != http.StatusForbidden { - t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String()) - } -} - func TestHandleHubTemplateByIDReturnsTemplate(t *testing.T) { hubSvc := mustNewLocalTemplateHubService(t, "review-bot", hub.Template{ ID: "review-bot", @@ -2581,7 +1779,7 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { @@ -2592,7 +1790,7 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { }, }), } - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"room_id":"room-1","inviter_id":"u-admin","user_ids":["u-manager"],"locale":"en"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"room_id":"room-1","inviter_id":"u-admin","user_ids":["manager"],"locale":"en"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() @@ -2608,14 +1806,14 @@ func TestHandleRoomsInviteAliasAddsConversationMembers(t *testing.T) { if got.ID != "room-1" { t.Fatalf("conversation id = %q, want %q", got.ID, "room-1") } - if !containsMember(got.Members, "u-manager") { - t.Fatalf("members = %+v, want u-manager to be invited", got.Members) + if !containsMember(got.Members, "manager") { + t.Fatalf("members = %+v, want manager to be invited", got.Members) } } func TestHandleRoomsInviteRequiresRoomID(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["u-manager"]}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/invite", strings.NewReader(`{"inviter_id":"u-admin","user_ids":["manager"]}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2723,7 +1921,7 @@ func TestHandleUsersReturnsUserList(t *testing.T) { }), } - req := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/users", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2750,7 +1948,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) { imBus: bus, } - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-alice","name":"Alice","handle":"alice","role":"worker"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-alice","name":"Alice","handle":"alice","role":"worker"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2784,7 +1982,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) { } } -func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { +func TestHandleUsersCreateWithParticipantServiceCreatesWorkerAgent(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) @@ -2794,22 +1992,19 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { events, cancel := bus.Subscribe() defer cancel() - store, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("bot.NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) - if err != nil { - t.Fatalf("bot.NewServiceWithDependencies() error = %v", err) - } + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) srv := &Handler{ - svc: agentSvc, - botSvc: botSvc, - im: imSvc, - imBus: bus, + svc: agentSvc, + participant: participantSvc, + im: imSvc, + imBus: bus, } - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-qa","name":"qa","handle":"qa","role":"qa"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-qa","name":"qa","handle":"qa","role":"qa"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2832,12 +2027,9 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { t.Fatalf("agent = %+v, want qa worker", created) } - bots, err := botSvc.List(string(bot.ChannelCSGClaw), string(bot.RoleWorker), "") - if err != nil { - t.Fatalf("List(worker) error = %v", err) - } - if len(bots) != 1 || bots[0].ID != "u-qa" || bots[0].AgentID != "u-qa" || bots[0].UserID != "u-qa" { - t.Fatalf("bots = %+v, want one qa worker bot", bots) + participants := participantSvc.List(participant.ListOptions{Channel: participant.ChannelCSGClaw, Type: participant.TypeAgent}) + if len(participants) != 1 || participants[0].ID != "qa" || participants[0].AgentID != "u-qa" || participants[0].ChannelUserRef != "u-qa" { + t.Fatalf("participants = %+v, want one qa worker participant", participants) } first := mustReceiveIMEvent(t, events) @@ -2850,10 +2042,81 @@ func TestHandleUsersCreateWithBotServiceCreatesWorkerAgent(t *testing.T) { } } +func TestHandleUsersCreateManagerAgentIDReturnsParticipantUser(t *testing.T) { + imSvc := im.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-manager","name":"manager","handle":"manager","role":"manager"}`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var got im.User + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.ID != agent.ManagerParticipantID || got.Handle != agent.ManagerName { + t.Fatalf("user = %+v, want existing manager participant user", got) + } + if _, ok := imSvc.User(agent.ManagerUserID); ok { + t.Fatalf("legacy runtime user %q was created", agent.ManagerUserID) + } +} + +func TestHandleCreateRoomResolvesManagerAgentIDToParticipantUser(t *testing.T) { + imSvc := im.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + } + + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/rooms", strings.NewReader(`{"title":"manager dm","creator_id":"u-admin","member_ids":["u-manager"]}`)) + rec := httptest.NewRecorder() + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var got im.Room + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if !containsMember(got.Members, agent.ManagerParticipantID) || containsMember(got.Members, agent.ManagerUserID) { + t.Fatalf("room members = %+v, want manager participant user only", got.Members) + } +} + func TestHandleUsersCreateDefaultsHandleFromName(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"id":"u-alice","name":"Alice"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"id":"u-alice","name":"Alice"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2873,7 +2136,7 @@ func TestHandleUsersCreateDefaultsHandleFromName(t *testing.T) { func TestHandleUsersCreateRejectsMissingID(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodPost, "/api/v1/users", strings.NewReader(`{"name":"Alice","handle":"alice"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/users", strings.NewReader(`{"name":"Alice","handle":"alice"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -2966,13 +2229,13 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, }, }, }), @@ -2993,8 +2256,8 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) { if got.SenderID != "u-admin" || got.Content != "hello @manager" { t.Fatalf("message = %+v, want sender/content populated", got) } - if len(got.Mentions) != 1 || got.Mentions[0].ID != "u-manager" || got.Mentions[0].Name != "manager" { - t.Fatalf("mentions = %+v, want u-manager", got.Mentions) + if len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" || got.Mentions[0].Name != "manager" { + t.Fatalf("mentions = %+v, want manager", got.Mentions) } } @@ -3079,17 +2342,17 @@ func TestHandleThreadRoutesAndMessageFiltering(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ {ID: "msg-1", SenderID: "u-admin", Content: "before", CreatedAt: time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)}, {ID: "msg-root", SenderID: "u-admin", Content: "root", CreatedAt: time.Date(2026, 5, 20, 9, 1, 0, 0, time.UTC)}, - {ID: "msg-2", SenderID: "u-manager", Content: "after", CreatedAt: time.Date(2026, 5, 20, 9, 2, 0, 0, time.UTC)}, + {ID: "msg-2", SenderID: "manager", Content: "after", CreatedAt: time.Date(2026, 5, 20, 9, 2, 0, 0, time.UTC)}, }, }, }, @@ -3110,7 +2373,7 @@ func TestHandleThreadRoutesAndMessageFiltering(t *testing.T) { t.Fatalf("started thread = %+v, want root with context", started) } - req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"u-manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) + req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) rec = httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusCreated { @@ -3220,13 +2483,13 @@ func TestHandleThreadEventsPublishCreatedAndUpdated(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", Title: "Room One", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{{ID: "msg-root", SenderID: "u-admin", Content: "root", CreatedAt: time.Date(2026, 5, 20, 9, 0, 0, 0, time.UTC)}}, }, }, @@ -3245,7 +2508,7 @@ func TestHandleThreadEventsPublishCreatedAndUpdated(t *testing.T) { t.Fatalf("created event = %+v, want thread.created for msg-root", created) } - req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"u-manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) + req = httptest.NewRequest(http.MethodPost, "/api/v1/messages", strings.NewReader(`{"room_id":"room-1","sender_id":"manager","content":"thread reply","relates_to":{"rel_type":"m.thread","event_id":"msg-root"}}`)) rec = httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusCreated { @@ -3432,7 +2695,7 @@ func TestHandleFeishuEventsStreamsMessageBusEvents(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil).WithContext(ctx) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil).WithContext(ctx) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -3504,7 +2767,7 @@ func TestHandleFeishuEventsSendsHeartbeat(t *testing.T) { srv := &Handler{feishu: feishuSvc, serverAccessToken: "secret"} ctx, cancel := context.WithCancel(context.Background()) - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil).WithContext(ctx) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil).WithContext(ctx) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() @@ -3529,7 +2792,7 @@ func TestHandleFeishuEventsRequiresAuthorization(t *testing.T) { serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3541,7 +2804,7 @@ func TestHandleFeishuEventsRequiresAuthorization(t *testing.T) { func TestHandleFeishuEventsRequiresAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { srv := &Handler{} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3553,7 +2816,7 @@ func TestHandleFeishuEventsRequiresAuthorizationWhenServerAccessTokenEmpty(t *te func TestHandleFeishuEventsSkipsAuthorizationWhenNoAuth(t *testing.T) { srv := &Handler{serverNoAuth: true} - req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/bots/u-manager/events", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/u-manager/events", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3621,12 +2884,12 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) { Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, {ID: "u-alice", Name: "Alice", Handle: "alice"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, }), } - req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms", strings.NewReader(`{"title":"Launch","description":"coordination","creator_id":"u-admin","member_ids":["u-alice","u-manager"],"locale":"en"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/rooms", strings.NewReader(`{"title":"Launch","description":"coordination","creator_id":"u-admin","member_ids":["u-alice","manager"],"locale":"en"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3641,7 +2904,7 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) { if got.Title != "Launch" { t.Fatalf("conversation.Title = %q, want Launch", got.Title) } - if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "u-manager") { + if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") { t.Fatalf("members = %+v, want admin, alice, and manager", got.Members) } } @@ -3693,7 +2956,7 @@ func TestHandleUsersDeleteRemovesUser(t *testing.T) { }), } - req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-alice", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-alice", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3711,7 +2974,7 @@ func TestHandleUsersDeleteRemovesUser(t *testing.T) { func TestHandleUsersDeleteCurrentUserReturnsConflict(t *testing.T) { srv := &Handler{im: im.NewService()} - req := httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-admin", nil) + req := httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-admin", nil) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3786,14 +3049,14 @@ func TestHandleFeishuRoomsDeleteRemovesRoom(t *testing.T) { } } -func TestHandleBotCompatibilityRoutesRequireAuthorization(t *testing.T) { +func TestHandleParticipantMessageRouteRequiresAuthorization(t *testing.T) { srv := &Handler{ im: im.NewService(), botBridge: im.NewBotBridge("secret"), serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3802,12 +3065,12 @@ func TestHandleBotCompatibilityRoutesRequireAuthorization(t *testing.T) { } } -func TestHandleBotCompatibilityRoutesRequireAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { +func TestHandleParticipantMessageRouteRequiresAuthorizationWhenServerAccessTokenEmpty(t *testing.T) { srv := &Handler{ botBridge: im.NewBotBridge("secret"), } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3816,13 +3079,13 @@ func TestHandleBotCompatibilityRoutesRequireAuthorizationWhenServerAccessTokenEm } } -func TestHandleBotCompatibilityRoutesSkipAuthorizationWhenNoAuth(t *testing.T) { +func TestHandleParticipantMessageRouteSkipsAuthorizationWhenNoAuth(t *testing.T) { srv := &Handler{ botBridge: im.NewBotBridge("secret"), serverNoAuth: true, } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3837,7 +3100,7 @@ func TestHandleBotSendMessageRequiresIMService(t *testing.T) { serverNoAuth: true, } - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{"room_id":"room-1","text":"hello"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -3852,15 +3115,15 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ - {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + {ID: "msg-root", SenderID: "manager", Content: "How can I help?", CreatedAt: now}, }, }, }, @@ -3889,7 +3152,7 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { t.Fatal("User(u-admin) = false, want user") } bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() bridge.PublishMessageEvent(room, sender, inbound) select { @@ -3897,13 +3160,13 @@ func TestHandleBotSendMessageDoesNotInferRecentThreadScope(t *testing.T) { if evt.ThreadRootID != "msg-root" { t.Fatalf("bot event ThreadRootID = %q, want msg-root", evt.ThreadRootID) } - bridge.Ack("u-manager", evt.MessageID) + bridge.Ack("manager", evt.MessageID) case <-time.After(time.Second): t.Fatal("PublishMessageEvent() timed out waiting for threaded event") } srv := &Handler{im: imSvc, botBridge: bridge, serverNoAuth: true} - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{"room_id":"room-1","text":"thread answer"}`)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(`{"room_id":"room-1","text":"thread answer"}`)) rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { @@ -3941,15 +3204,15 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ - {ID: "msg-root", SenderID: "u-manager", Content: "How can I help?", CreatedAt: now}, + {ID: "msg-root", SenderID: "manager", Content: "How can I help?", CreatedAt: now}, }, }, }, @@ -3959,7 +3222,7 @@ func TestHandleBotSendMessageAcceptsPicoClawThreadContext(t *testing.T) { } srv := &Handler{im: imSvc, botBridge: im.NewBotBridge(""), serverNoAuth: true} - req := httptest.NewRequest(http.MethodPost, "/api/bots/u-manager/messages/send", strings.NewReader(`{ + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/manager/messages", strings.NewReader(`{ "chat_id": "room-1", "content": "direct PicoClaw thread answer", "context": { @@ -4009,13 +3272,13 @@ func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-pending", @@ -4046,7 +3309,7 @@ func TestPublishBotEventQueuesUntilBotSubscribes(t *testing.T) { Message: &room.Messages[0], }) - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() select { @@ -4232,13 +3495,13 @@ func TestHandleBotEventsRequeuesWhenSSEWriteFails(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-retry", @@ -4262,10 +3525,10 @@ func TestHandleBotEventsRequeuesWhenSSEWriteFails(t *testing.T) { bridge.PublishMessageEvent(room, sender, room.Messages[0]) srv := &Handler{im: imSvc, botBridge: bridge} - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/events", nil) - srv.handleBotEvents(&failingBotEventWriter{header: make(http.Header)}, req, "u-manager") + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/manager/events", nil) + srv.handleBotEvents(&failingBotEventWriter{header: make(http.Header)}, req, "manager") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() select { case evt := <-events: @@ -4303,13 +3566,13 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-missed", @@ -4322,11 +3585,11 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { }, }) bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv.replayRecentBotMessages("manager", "") select { case evt := <-events: @@ -4338,6 +3601,108 @@ func TestReplayRecentBotMessagesReplaysUnansweredHumanMessage(t *testing.T) { } } +func TestReplayRecentBotMessagesSkipsRoomWithoutBridgeTarget(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{ + { + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{ + { + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }, + }, + }, + }, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewBotBridge("") + events, cancel := bridge.Subscribe(agent.ManagerParticipantID) + defer cancel() + + srv := &Handler{im: imSvc, participant: participantSvc, botBridge: bridge} + srv.replayRecentBotMessages(agent.ManagerParticipantID, "") + + select { + case evt := <-events: + t.Fatalf("replayed event = %+v, want no replay for room without manager membership", evt) + case <-time.After(50 * time.Millisecond): + } +} + +func TestReplayRecentBotMessagesReplaysParticipantRoomUsingChannelUserRef(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{ + { + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{ + { + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }, + }, + }, + }, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewBotBridge("") + events, cancel := bridge.Subscribe("agent-hhtz4b") + defer cancel() + + srv := &Handler{im: imSvc, participant: participantSvc, botBridge: bridge} + srv.replayRecentBotMessages("agent-hhtz4b", "") + + select { + case evt := <-events: + if evt.MessageID != "msg-qa" || evt.Context.Account != "agent-hhtz4b" { + t.Fatalf("replayed event = %+v, want participant-keyed QA replay", evt) + } + case <-time.After(time.Second): + t.Fatal("replayRecentBotMessages() timed out waiting for participant-keyed QA event") + } +} + func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { t.Setenv("HOME", t.TempDir()) restoreDefault := agent.TestOnlySetDefaultServiceOption(func(s *agent.Service) error { @@ -4383,13 +3748,13 @@ func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-new-convo", @@ -4402,11 +3767,11 @@ func TestReplayRecentBotMessagesUsesNewConversationFlow(t *testing.T) { }, }) bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() srv := &Handler{svc: svc, im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv.replayRecentBotMessages("manager", "") select { case evt := <-events: @@ -4424,13 +3789,13 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-answered", @@ -4440,7 +3805,7 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { }, { ID: "msg-reply", - SenderID: "u-manager", + SenderID: "manager", Content: "done", CreatedAt: now.Add(time.Second), }, @@ -4449,11 +3814,11 @@ func TestReplayRecentBotMessagesSkipsAnsweredMessage(t *testing.T) { }, }) bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv.replayRecentBotMessages("manager", "") select { case evt := <-events: @@ -4468,13 +3833,13 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-delivered", @@ -4487,7 +3852,7 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { }, }) bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() room, ok := imSvc.Room("room-1") @@ -4505,13 +3870,13 @@ func TestReplayRecentBotMessagesDoesNotDuplicateDeliveredMessage(t *testing.T) { if evt.MessageID != "msg-delivered" { t.Fatalf("live event = %+v, want msg-delivered", evt) } - bridge.Ack("u-manager", evt.MessageID) + bridge.Ack("manager", evt.MessageID) case <-time.After(time.Second): t.Fatal("PublishMessageEvent() timed out waiting for event") } srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "") + srv.replayRecentBotMessages("manager", "") select { case evt := <-events: @@ -4526,13 +3891,13 @@ func TestReplayRecentBotMessagesHonorsLastEventID(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []im.Room{ { ID: "room-1", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []im.Message{ { ID: "msg-seen", @@ -4551,11 +3916,11 @@ func TestReplayRecentBotMessagesHonorsLastEventID(t *testing.T) { }, }) bridge := im.NewBotBridge("") - events, cancel := bridge.Subscribe("u-manager") + events, cancel := bridge.Subscribe("manager") defer cancel() srv := &Handler{im: imSvc, botBridge: bridge} - srv.replayRecentBotMessages("u-manager", "msg-seen") + srv.replayRecentBotMessages("manager", "msg-seen") select { case evt := <-events: @@ -4622,7 +3987,7 @@ func TestHandleBotLLMModelsReturnsBridgeCatalog(t *testing.T) { serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/v1/models", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager/llm/v1/models", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) @@ -4684,7 +4049,7 @@ func TestHandleBotLLMModelsLegacyRouteReturnsBridgeCatalog(t *testing.T) { serverAccessToken: "secret", } - req := httptest.NewRequest(http.MethodGet, "/api/bots/u-manager/llm/models", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager/llm/models", nil) req.Header.Set("Authorization", "Bearer secret") rec := httptest.NewRecorder() srv.Routes().ServeHTTP(rec, req) diff --git a/internal/api/llm.go b/internal/api/llm.go index 0268aee4..4b44d655 100644 --- a/internal/api/llm.go +++ b/internal/api/llm.go @@ -8,6 +8,51 @@ import ( "csgclaw/internal/llm" ) +func (h *Handler) handleAgentLLMModelsByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMModels(w, r, agentID) +} + +func (h *Handler) handleAgentLLMChatCompletionsByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMChatCompletions(w, r, agentID) +} + +func (h *Handler) handleAgentLLMResponsesByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMResponses(w, r, agentID) +} + +func (h *Handler) handleAgentLLMResponsesWebsocketByID(w http.ResponseWriter, r *http.Request) { + agentID, ok := h.requireAgentLLMID(w, r) + if !ok { + return + } + h.handleBotLLMResponsesWebsocket(w, r, agentID) +} + +func (h *Handler) requireAgentLLMID(w http.ResponseWriter, r *http.Request) (string, bool) { + agentID := strings.TrimSpace(pathValue(r, "id")) + if agentID == "" { + http.NotFound(w, r) + return "", false + } + if !h.validateServerAccessToken(r.Header.Get("Authorization")) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + return agentID, true +} + func (h *Handler) handleBotLLMModels(w http.ResponseWriter, r *http.Request, botID string) { if h.llm == nil { http.Error(w, "llm bridge is not configured", http.StatusServiceUnavailable) diff --git a/internal/api/notification_bots.go b/internal/api/notification_bots.go index df3f566e..0192c967 100644 --- a/internal/api/notification_bots.go +++ b/internal/api/notification_bots.go @@ -2,36 +2,40 @@ package api import ( "net/http" + "strings" "csgclaw/internal/channel/csgclaw/notification_bot" + participantpkg "csgclaw/internal/participant" ) -func (h *Handler) pushNotificationBot(w http.ResponseWriter, r *http.Request) { +func (h *Handler) pushNotificationParticipant(w http.ResponseWriter, r *http.Request) { id := pathValue(r, "id") if id == "" { http.NotFound(w, r) return } - channel := botChannelName(r) + channel := participantChannelName(pathValue(r, "channel")) if channel == "" { - channel = "csgclaw" + http.NotFound(w, r) + return } - deps := h.notificationPushDeps(channel) + deps := h.notificationParticipantPushDeps(channel) notification_bot.ServeNotificationPush(w, r, id, deps) } -func (h *Handler) notificationPushDeps(channel string) notification_bot.PushHTTPDeps { - var reload func() error - var lookup func(string) (map[string]any, string, bool) - if h.botSvc != nil { - reload = h.botSvc.Reload - lookup = func(id string) (map[string]any, string, bool) { - return h.botSvc.LookupNotificationBotForDelivery(channel, id) - } - } +func (h *Handler) notificationParticipantPushDeps(channel string) notification_bot.PushHTTPDeps { return notification_bot.PushHTTPDeps{ - Reload: reload, - LookupNotificationBot: lookup, - Deliver: h.notificationDeliver, + Reload: func() error { return nil }, + LookupNotificationBot: func(id string) (map[string]any, string, bool) { + if h.participant == nil { + return nil, "", false + } + item, ok := h.participant.Get(channel, id) + if ok && strings.EqualFold(item.Type, participantpkg.TypeNotification) { + return item.Metadata, item.ChannelUserRef, true + } + return nil, "", false + }, + Deliver: h.notificationDeliver, } } diff --git a/internal/api/notification_bots_handler_test.go b/internal/api/notification_bots_handler_test.go deleted file mode 100644 index 6774f585..00000000 --- a/internal/api/notification_bots_handler_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package api - -import ( - "net/http" - "net/http/httptest" - "testing" - - "csgclaw/internal/bot" - "csgclaw/internal/im" -) - -func TestHandleBotByIDRejectsGetPatchForNormalBot(t *testing.T) { - imSvc := im.NewService() - botStore, err := bot.NewMemoryStore([]bot.Bot{{ - ID: "u-worker", - Name: "worker", - Type: bot.BotTypeNormal, - Role: string(bot.RoleWorker), - Channel: string(bot.ChannelCSGClaw), - AgentID: "u-worker", - UserID: "u-worker", - }}) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - srv := &Handler{botSvc: botSvc, im: imSvc} - router := srv.Routes() - - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots/u-worker", nil)) - if rec.Code != http.StatusMethodNotAllowed { - t.Fatalf("GET normal bot status = %d, want 405", rec.Code) - } -} diff --git a/internal/api/notification_bots_test.go b/internal/api/notification_bots_test.go deleted file mode 100644 index 3fa928f1..00000000 --- a/internal/api/notification_bots_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "csgclaw/internal/apitypes" - "csgclaw/internal/bot" - "csgclaw/internal/im" -) - -func TestNotificationBotsCRUDAndListBotsFilter(t *testing.T) { - imSvc := im.NewService() - bus := im.NewBus() - botStore, err := bot.NewMemoryStore(nil) - if err != nil { - t.Fatalf("NewMemoryStore() error = %v", err) - } - botSvc, err := bot.NewService(botStore) - if err != nil { - t.Fatalf("NewService() error = %v", err) - } - botSvc.SetDependencies(nil, imSvc) - - srv := &Handler{botSvc: botSvc, im: imSvc, imBus: bus} - router := srv.Routes() - - createBody, _ := json.Marshal(apitypes.CreateBotRequest{ - Name: "notify-1", - Type: "notification", - Role: "worker", - RuntimeOptions: map[string]any{ - "delivery_mode": "webhook", - "webhook_token": "secret-token", - }, - }) - rec := httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots", bytes.NewReader(createBody))) - if rec.Code != http.StatusCreated { - t.Fatalf("POST notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - var created apitypes.Bot - if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil { - t.Fatalf("decode created: %v", err) - } - if created.Type != bot.BotTypeNotification { - t.Fatalf("created.Type = %q, want %q", created.Type, bot.BotTypeNotification) - } - if created.ID != "n-notify-1" { - t.Fatalf("created.ID = %q, want n-notify-1", created.ID) - } - if created.AgentID != "" { - t.Fatalf("created.AgentID = %q, want empty", created.AgentID) - } - - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/bots", nil)) - if rec.Code != http.StatusOK { - t.Fatalf("GET bots status = %d", rec.Code) - } - var listed []apitypes.Bot - if err := json.Unmarshal(rec.Body.Bytes(), &listed); err != nil { - t.Fatalf("decode bots: %v", err) - } - var found bool - for _, b := range listed { - if b.ID == created.ID && b.Type == bot.BotTypeNotification { - found = true - break - } - } - if !found { - t.Fatalf("GET /bots = %+v, want notification bot %q", listed, created.ID) - } - - events, cancel := bus.Subscribe() - defer cancel() - patchBody, _ := json.Marshal(apitypes.PatchNotificationBotRequest{ - Avatar: "avatar/cartoon-4.png", - }) - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodPatch, "/api/v1/channels/csgclaw/bots/"+created.ID, bytes.NewReader(patchBody))) - if rec.Code != http.StatusOK { - t.Fatalf("PATCH notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - user, ok := imSvc.User(created.UserID) - if !ok { - t.Fatalf("User(%q) ok = false, want true", created.UserID) - } - if user.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("user avatar = %q, want avatar/cartoon-4.png", user.Avatar) - } - evt := mustReceiveIMEvent(t, events) - if evt.Type != im.EventTypeUserUpdated || evt.User == nil || evt.User.Avatar != "avatar/cartoon-4.png" { - t.Fatalf("event = %+v, want user.updated with updated avatar", evt) - } - - push := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/bots/"+created.ID+"/notifications", bytes.NewReader([]byte(`{"hello":"world"}`))) - req.Header.Set("Authorization", "Bearer secret-token") - req.Header.Set("Content-Type", "application/json") - srv.SetNotificationDeliver(&noopFanouter{}) - router.ServeHTTP(push, req) - if push.Code != http.StatusAccepted { - t.Fatalf("POST notifications status = %d, body = %s", push.Code, push.Body.String()) - } - - rec = httptest.NewRecorder() - router.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/bots/"+created.ID, nil)) - if rec.Code != http.StatusNoContent { - t.Fatalf("DELETE notification-bots status = %d, body = %s", rec.Code, rec.Body.String()) - } - _ = context.Background() -} - -type noopFanouter struct{} - -func (noopFanouter) DeliverFanout(string, string) error { return nil } diff --git a/internal/api/participant.go b/internal/api/participant.go new file mode 100644 index 00000000..c597af89 --- /dev/null +++ b/internal/api/participant.go @@ -0,0 +1,230 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/participant" +) + +func (h *Handler) handleParticipants(w http.ResponseWriter, r *http.Request) { + if h.participant == nil { + http.Error(w, "participant service is not configured", http.StatusServiceUnavailable) + return + } + channelName := pathValue(r, "channel") + if channelName == "" { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + items := h.participant.List(participant.ListOptions{ + Channel: channelName, + Type: r.URL.Query().Get("type"), + AgentID: r.URL.Query().Get("agent_id"), + }) + writeJSON(w, http.StatusOK, items) + case http.MethodPost: + var req participant.CreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + req.Channel = channelName + created, err := h.participant.Create(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusCreated, created) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleParticipantByIDPath(w http.ResponseWriter, r *http.Request) { + if h.participant == nil { + http.Error(w, "participant service is not configured", http.StatusServiceUnavailable) + return + } + channelName := pathValue(r, "channel") + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + + switch r.Method { + case http.MethodGet: + item, ok := h.participant.Get(channelName, id) + if !ok { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, item) + case http.MethodPatch: + var req participant.UpdateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + updated, ok, err := h.participant.Update(r.Context(), channelName, id, req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !ok { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, updated) + case http.MethodDelete: + _, ok, err := h.participant.Delete(r.Context(), channelName, id, participant.DeleteOptions{ + DeleteAgent: r.URL.Query().Get("delete_agent"), + }) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if !ok { + http.NotFound(w, r) + return + } + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (h *Handler) handleParticipantEvents(w http.ResponseWriter, r *http.Request) { + channelName := participantChannelName(pathValue(r, "channel")) + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + + switch channelName { + case "csgclaw": + participantID, ok := h.requireParticipantBridgeID(w, r, h.resolveParticipantBridgeID(channelName, id)) + if !ok { + return + } + h.handleBotEvents(w, r, participantID) + case "feishu": + h.handleFeishuParticipantEvents(w, r, h.resolveFeishuParticipantTargetID(id)) + default: + http.NotFound(w, r) + } +} + +func (h *Handler) handleParticipantMessage(w http.ResponseWriter, r *http.Request) { + channelName := participantChannelName(pathValue(r, "channel")) + id := pathValue(r, "id") + if channelName == "" || id == "" { + http.NotFound(w, r) + return + } + if channelName != "csgclaw" { + http.NotFound(w, r) + return + } + participantID := h.resolveParticipantChannelUserID(channelName, id) + participantID, ok := h.requireParticipantBridgeID(w, r, participantID) + if !ok { + return + } + h.handleBotSendMessage(w, r, participantID) +} + +func (h *Handler) resolveParticipantChannelUserID(channelName, id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(channelName, id); ok { + return participantChannelUserOrID(item) + } + if strings.EqualFold(channelName, participant.ChannelCSGClaw) { + for _, item := range h.participant.List(participant.ListOptions{Channel: channelName}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, id) { + continue + } + return participantChannelUserOrID(item) + } + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + +func (h *Handler) resolveParticipantBridgeID(channelName, id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(channelName, id); ok && isCSGClawAgentParticipant(item) { + return strings.TrimSpace(item.ID) + } + if strings.EqualFold(channelName, participant.ChannelCSGClaw) { + for _, item := range h.participant.List(participant.ListOptions{Channel: channelName}) { + if !isCSGClawAgentParticipant(item) || !participantMatchesIdentity(item, id) { + continue + } + return strings.TrimSpace(item.ID) + } + } + } + if id == agent.ManagerUserID { + return agent.ManagerParticipantID + } + return id +} + +func (h *Handler) resolveFeishuParticipantTargetID(id string) string { + id = strings.TrimSpace(id) + if h != nil && h.participant != nil { + if item, ok := h.participant.Get(participant.ChannelFeishu, id); ok { + return participantChannelUserOrID(item) + } + for _, item := range h.participant.List(participant.ListOptions{Channel: participant.ChannelFeishu}) { + if !participantMatchesIdentity(item, id) { + continue + } + return participantChannelUserOrID(item) + } + } + return id +} + +func participantChannelUserOrID(item apitypes.Participant) string { + if ref := strings.TrimSpace(item.ChannelUserRef); ref != "" { + return ref + } + return strings.TrimSpace(item.ID) +} + +func (h *Handler) requireParticipantBridgeID(w http.ResponseWriter, r *http.Request, id string) (string, bool) { + id = strings.TrimSpace(id) + if id == "" { + http.NotFound(w, r) + return "", false + } + if h.botBridge == nil { + http.Error(w, "picoclaw integration is not configured", http.StatusServiceUnavailable) + return "", false + } + if !h.validateServerAccessToken(r.Header.Get("Authorization")) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return "", false + } + return id, true +} + +func participantChannelName(channel string) string { + return strings.TrimSpace(channel) +} diff --git a/internal/api/participant_test.go b/internal/api/participant_test.go new file mode 100644 index 00000000..ab8fb1fc --- /dev/null +++ b/internal/api/participant_test.go @@ -0,0 +1,694 @@ +package api + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + csgclawchannel "csgclaw/internal/channel/csgclaw" + "csgclaw/internal/channel/feishu" + "csgclaw/internal/im" + "csgclaw/internal/participant" +) + +func TestCreateCSGClawAgentParticipantViaAPI(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, nil) + imSvc := im.NewService() + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) + srv := &Handler{ + svc: agentSvc, + im: imSvc, + participant: participantSvc, + } + + body := `{ + "id": "qa", + "type": "agent", + "name": "QA Display Name", + "channel_user": { + "ref": "u-qa", + "kind": "local_user_id" + }, + "agent_binding": { + "mode": "create", + "agent": { + "name": "QA Display Name", + "role": "worker", + "runtime_kind": "picoclaw_sandbox", + "image": "agent-image:test" + } + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "qa" || created.Channel != "csgclaw" || created.Type != "agent" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want csgclaw agent qa bound to u-qa", created) + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was not created") + } + if _, ok := agentSvc.Agent("u-qa-display-name"); ok { + t.Fatal("agent ID was derived from display name") + } + if user, ok := imSvc.User("u-qa"); !ok || !strings.EqualFold(user.Name, "QA Display Name") { + t.Fatalf("channel user = %+v, ok=%v; want u-qa display user", user, ok) + } +} + +func TestCreateFeishuAgentParticipantViaAPIReusesExistingAgent(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, []agent.Agent{{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }}) + participantSvc := participant.NewService( + participant.NewMemoryStore(nil), + participant.WithAgentService(agentSvc), + ) + srv := &Handler{ + svc: agentSvc, + participant: participantSvc, + } + + body := `{ + "id": "test", + "type": "agent", + "name": "QA Feishu", + "channel_app_ref": "cli_xxx", + "channel_user": { + "ref": "ou_xxx", + "kind": "open_id" + }, + "agent_binding": { + "mode": "reuse", + "agent_id": "u-qa" + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "test" || created.Channel != "feishu" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want feishu:test bound to u-qa", created) + } + if created.ChannelUserRef != "ou_xxx" || created.ChannelUserKind != "open_id" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created channel identity = %+v, want Feishu app/open_id identity", created) + } +} + +func TestCreateFeishuHumanParticipantViaAPI(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore(nil)) + srv := &Handler{participant: participantSvc} + + body := `{ + "id": "alice", + "type": "human", + "name": "Alice", + "channel_app_ref": "cli_xxx", + "channel_user": { + "ref": "ou_alice", + "kind": "open_id" + } + }` + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/feishu/participants", strings.NewReader(body)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + var created apitypes.Participant + if err := json.NewDecoder(rec.Body).Decode(&created); err != nil { + t.Fatalf("decode response: %v", err) + } + if created.ID != "alice" || created.Type != "human" || created.AgentID != "" { + t.Fatalf("created participant = %+v, want unbound human alice", created) + } + if created.ChannelUserRef != "ou_alice" || created.ChannelUserKind != "open_id" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created channel identity = %+v, want Feishu human open_id identity", created) + } +} + +func TestListAgentsIncludesParticipantsWhenRequested(t *testing.T) { + agentSvc, _ := mustNewSeededServiceWithPath(t, []agent.Agent{{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + }}) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "qa", + Channel: "csgclaw", + Type: "agent", + Name: "QA", + ChannelUserRef: "u-qa", + AgentID: "u-qa", + Mentionable: true, + }})) + srv := &Handler{ + svc: agentSvc, + participant: participantSvc, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents?include_participants=true", nil) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var agents []map[string]any + if err := json.NewDecoder(rec.Body).Decode(&agents); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(agents) != 1 { + t.Fatalf("agents = %+v, want one agent", agents) + } + participants, ok := agents[0]["participants"].([]any) + if !ok || len(participants) != 1 { + t.Fatalf("participants = %#v, want one participant", agents[0]["participants"]) + } + got, ok := participants[0].(map[string]any) + if !ok || got["id"] != "qa" || got["channel"] != "csgclaw" { + t.Fatalf("participant expansion = %#v, want csgclaw qa", participants[0]) + } +} + +func TestParticipantMessageRouteSendsAsParticipantChannelUser(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-bob", Name: "bob", Handle: "bob"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-bob"}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "bob", + Channel: "csgclaw", + Type: "human", + Name: "Bob", + ChannelUserRef: "u-bob", + ChannelUserKind: "local_user_id", + LifecycleStatus: "active", + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + botBridge: im.NewBotBridge("secret"), + serverAccessToken: "secret", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/bob/messages", strings.NewReader(`{ + "room_id": "room-1", + "content": "hello from participant route" + }`)) + req.Header.Set("Authorization", "Bearer secret") + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + messages, err := imSvc.ListMessages("room-1") + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("messages = %+v, want one delivered message", messages) + } + if messages[0].SenderID != "u-bob" || messages[0].Content != "hello from participant route" { + t.Fatalf("delivered message = %+v, want sender u-bob with posted content", messages[0]) + } +} + +func TestParticipantMessageRouteCanonicalizesAgentIDAlias(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + botBridge: im.NewBotBridge(""), + serverNoAuth: true, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/u-manager/messages", strings.NewReader(`{ + "room_id": "room-1", + "text": "hello from manager alias" + }`)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + messages, err := imSvc.ListMessages("room-1") + if err != nil { + t.Fatalf("ListMessages() error = %v", err) + } + if len(messages) != 1 { + t.Fatalf("messages = %+v, want one delivered message", messages) + } + if messages[0].SenderID != agent.ManagerParticipantID || messages[0].Content != "hello from manager alias" { + t.Fatalf("delivered message = %+v, want canonical manager participant sender", messages[0]) + } +} + +func TestParticipantNotificationRouteAcceptsNotificationParticipant(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "alerts", + Channel: "csgclaw", + Type: "notification", + Name: "Alerts", + ChannelUserRef: "n-alerts", + ChannelUserKind: "local_user_id", + LifecycleStatus: "active", + Mentionable: true, + Metadata: map[string]any{ + "delivery_mode": "webhook", + "webhook_token": "secret-token", + }, + }})) + srv := &Handler{participant: participantSvc} + srv.SetNotificationDeliver(&noopFanouter{}) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/participants/alerts/notifications", strings.NewReader(`{"hello":"world"}`)) + req.Header.Set("Authorization", "Bearer secret-token") + req.Header.Set("Content-Type", "application/json") + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String()) + } +} + +func TestParticipantEventsRouteRequiresAuthorization(t *testing.T) { + srv := &Handler{ + im: im.NewService(), + botBridge: im.NewBotBridge("secret"), + serverAccessToken: "secret", + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/u-manager/events", nil) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) + } +} + +func TestParticipantEventsRouteCanonicalizesAgentIDAlias(t *testing.T) { + now := time.Now().UTC() + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{{ + ID: "room-qa", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{{ + ID: "msg-qa", + SenderID: "u-admin", + Content: "qa only", + CreatedAt: now, + }}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + im: imSvc, + participant: participantSvc, + botBridge: im.NewBotBridge(""), + serverNoAuth: true, + } + + writer := &recordingFailingEventWriter{header: make(http.Header)} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/u-agent-hhtz4b/events", nil).WithContext(ctx) + + srv.Routes().ServeHTTP(writer, req) + + if got := writer.String(); !strings.Contains(got, `"message_id":"msg-qa"`) || !strings.Contains(got, `"account":"agent-hhtz4b"`) { + t.Fatalf("event stream = %q, want replay delivered on canonical participant id agent-hhtz4b", got) + } +} + +type recordingFailingEventWriter struct { + header http.Header + body strings.Builder +} + +func (w *recordingFailingEventWriter) Header() http.Header { + return w.header +} + +func (w *recordingFailingEventWriter) Write(data []byte) (int, error) { + w.body.Write(data) + if strings.Contains(string(data), "event: message") { + return 0, errors.New("stop after message event") + } + return len(data), nil +} + +func (w *recordingFailingEventWriter) WriteHeader(int) {} + +func (w *recordingFailingEventWriter) Flush() {} + +func (w *recordingFailingEventWriter) String() string { + return w.body.String() +} + +func TestCreateMessageResolvesCSGClawParticipantMentionToBridgeID(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewBotBridge("secret") + bus := im.NewBus() + events, cancel := bridge.Subscribe(agent.ManagerParticipantID) + defer cancel() + srv := &Handler{ + im: imSvc, + csgclaw: csgclawchannel.NewService(imSvc), + imBus: bus, + participant: participantSvc, + botBridge: bridge, + serverNoAuth: true, + } + busEvents, cancelBus := bus.Subscribe() + done := make(chan struct{}) + go func() { + defer close(done) + for evt := range busEvents { + srv.PublishBotEvent(evt) + } + }() + defer func() { + cancelBus() + <-done + }() + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/channels/csgclaw/messages", strings.NewReader(`{ + "room_id": "room-1", + "sender_id": "u-admin", + "mention_id": "manager", + "content": "hello manager" + }`)) + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusCreated { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusCreated, rec.Body.String()) + } + select { + case evt := <-events: + if evt.Context.Account != agent.ManagerParticipantID || len(evt.Mentions) != 1 || evt.Mentions[0] != agent.ManagerParticipantID { + t.Fatalf("event = %+v, want bridge delivery for participant %q", evt, agent.ManagerParticipantID) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for manager bridge event") + } +} + +func TestPublishBotEventDeliversToParticipantIDWhenRoomUsesChannelUserRef(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: "u-agent-hhtz4b", Name: "qa", Handle: "qa"}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", "u-agent-hhtz4b"}, + Messages: []im.Message{{ + ID: "msg-1", + SenderID: "u-admin", + Content: "hello qa", + CreatedAt: time.Now().UTC(), + }}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "agent-hhtz4b", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "qa", + ChannelUserRef: "u-agent-hhtz4b", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: "u-agent-hhtz4b", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewBotBridge("secret") + events, cancel := bridge.Subscribe("agent-hhtz4b") + defer cancel() + srv := &Handler{ + im: imSvc, + participant: participantSvc, + botBridge: bridge, + } + room, ok := imSvc.Room("room-1") + if !ok || len(room.Messages) != 1 { + t.Fatalf("room = %+v, want one message", room) + } + sender, ok := imSvc.User("u-admin") + if !ok { + t.Fatal("missing admin sender") + } + + srv.PublishBotEvent(im.Event{ + Type: im.EventTypeMessageCreated, + RoomID: "room-1", + Sender: &sender, + Message: &room.Messages[0], + }) + + select { + case evt := <-events: + if evt.MessageID != "msg-1" || evt.Context.Account != "agent-hhtz4b" { + t.Fatalf("event = %+v, want participant-keyed delivery for agent-hhtz4b", evt) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for participant-keyed bridge event") + } +} + +func TestParticipantEventsRouteReceivesParticipantIDQueue(t *testing.T) { + imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ + CurrentUserID: "u-admin", + Users: []im.User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + {ID: agent.ManagerUserID, Name: "manager", Handle: "manager", Role: agent.RoleManager}, + }, + Rooms: []im.Room{{ + ID: "room-1", + IsDirect: true, + Members: []string{"u-admin", agent.ManagerParticipantID}, + }}, + }) + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: agent.ManagerName, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + bridge := im.NewBotBridge("secret") + srv := &Handler{ + im: imSvc, + participant: participantSvc, + botBridge: bridge, + serverAccessToken: "secret", + } + ctx, cancelReq := context.WithCancel(context.Background()) + defer cancelReq() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/participants/manager/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer secret") + done := make(chan struct{}) + go func() { + srv.Routes().ServeHTTP(rec, req) + close(done) + }() + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return bridge.SubscriberCount(agent.ManagerParticipantID) > 0 + }) + if got := bridge.SubscriberCount(agent.ManagerUserID); got != 0 { + t.Fatalf("u-manager subscriber count = %d, want 0 because only participant ID should be used for CSGClaw delivery", got) + } + + room := im.Room{ID: "room-1", IsDirect: true, Members: []string{"u-admin", agent.ManagerParticipantID}} + sender := im.User{ID: "u-admin", Name: "admin", Handle: "admin"} + message := im.Message{ + ID: "msg-1", + SenderID: "u-admin", + Content: "hello manager", + CreatedAt: time.Now().UTC(), + } + bridge.PublishMessageEvent(room, sender, message) + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), `"message_id":"msg-1"`) + }) + cancelReq() + <-done +} + +func TestFeishuParticipantEventsRouteUsesParticipantChannelUserRef(t *testing.T) { + feishuSvc := feishu.NewService() + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{{ + ID: "qa", + Channel: participant.ChannelFeishu, + Type: participant.TypeAgent, + Name: "QA", + ChannelUserRef: "ou_qa", + ChannelUserKind: participant.ChannelUserKindOpenID, + AgentID: "u-qa", + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + }})) + srv := &Handler{ + feishu: feishuSvc, + participant: participantSvc, + serverAccessToken: "secret", + } + + ctx, cancelReq := context.WithCancel(context.Background()) + defer cancelReq() + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/feishu/participants/qa/events", nil).WithContext(ctx) + req.Header.Set("Authorization", "Bearer secret") + done := make(chan struct{}) + go func() { + srv.Routes().ServeHTTP(rec, req) + close(done) + }() + + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), ": connected") + }) + feishuSvc.MessageBus().Publish(feishu.MessageEvent{ + Type: feishu.MessageEventTypeMessageCreated, + RoomID: "oc_alpha", + Message: &im.Message{ + ID: "om_qa", + SenderID: "ou_user", + Content: "hello qa", + Mentions: []im.Mention{ + {ID: "ou_qa"}, + }, + }, + }) + waitForCondition(t, time.Second, 10*time.Millisecond, func() bool { + return strings.Contains(rec.Body.String(), `"id":"om_qa"`) + }) + cancelReq() + <-done +} + +type noopFanouter struct{} + +func (noopFanouter) DeliverFanout(string, string) error { return nil } diff --git a/internal/api/rest_handlers.go b/internal/api/rest_handlers.go index 3587448f..087787da 100644 --- a/internal/api/rest_handlers.go +++ b/internal/api/rest_handlers.go @@ -9,9 +9,24 @@ func (h *Handler) getUpgradeStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) createUpgradeApply(w http.ResponseWriter, r *http.Request) { h.handleUpgradeApply(w, r) } -func (h *Handler) listBots(w http.ResponseWriter, r *http.Request) { h.handleBots(w, r) } -func (h *Handler) createBot(w http.ResponseWriter, r *http.Request) { h.handleBots(w, r) } -func (h *Handler) deleteBot(w http.ResponseWriter, r *http.Request) { h.handleBotByID(w, r) } +func (h *Handler) listParticipants(w http.ResponseWriter, r *http.Request) { + h.handleParticipants(w, r) +} +func (h *Handler) createParticipant(w http.ResponseWriter, r *http.Request) { + h.handleParticipants(w, r) +} +func (h *Handler) handleParticipantByID(w http.ResponseWriter, r *http.Request) { + h.handleParticipantByIDPath(w, r) +} +func (h *Handler) getParticipantEvents(w http.ResponseWriter, r *http.Request) { + h.handleParticipantEvents(w, r) +} +func (h *Handler) createParticipantMessage(w http.ResponseWriter, r *http.Request) { + h.handleParticipantMessage(w, r) +} +func (h *Handler) createParticipantNotification(w http.ResponseWriter, r *http.Request) { + h.pushNotificationParticipant(w, r) +} func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) { h.handleAgents(w, r) } func (h *Handler) createAgent(w http.ResponseWriter, r *http.Request) { h.handleAgents(w, r) } func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) { h.handleAgentByID(w, r) } @@ -32,6 +47,18 @@ func (h *Handler) recreateAgent(w http.ResponseWriter, r *http.Request) { func (h *Handler) upgradeAgent(w http.ResponseWriter, r *http.Request) { h.handleAgentUpgradeByID(w, r) } +func (h *Handler) getAgentLLMModels(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMModelsByID(w, r) +} +func (h *Handler) createAgentLLMChatCompletions(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMChatCompletionsByID(w, r) +} +func (h *Handler) createAgentLLMResponses(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMResponsesByID(w, r) +} +func (h *Handler) getAgentLLMResponsesWebsocket(w http.ResponseWriter, r *http.Request) { + h.handleAgentLLMResponsesWebsocketByID(w, r) +} func (h *Handler) listHubTemplates(w http.ResponseWriter, r *http.Request) { h.handleHubTemplates(w, r) } @@ -111,9 +138,6 @@ func (h *Handler) updateFeishuConfig(w http.ResponseWriter, r *http.Request) { func (h *Handler) reloadFeishuConfig(w http.ResponseWriter, r *http.Request) { h.handleFeishuConfigReload(w) } -func (h *Handler) getFeishuBotEvents(w http.ResponseWriter, r *http.Request) { - h.handleFeishuBotByID(w, r) -} func (h *Handler) listFeishuUsers(w http.ResponseWriter, r *http.Request) { h.handleFeishuUsers(w, r) } func (h *Handler) createFeishuUser(w http.ResponseWriter, r *http.Request) { h.handleFeishuUsers(w, r) } func (h *Handler) deleteFeishuUser(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/router.go b/internal/api/router.go index 2f7a700c..564dee6a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -6,7 +6,6 @@ func (h *Handler) Routes() chi.Router { router := chi.NewRouter() h.registerCoreRoutes(router) h.registerChannelRoutes(router) - h.registerBotCompatibilityRoutes(router) return router } @@ -34,6 +33,16 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { r.Get("/", h.getAgentProfile) r.Put("/", h.updateAgentProfile) }) + r.Route("/llm", func(r chi.Router) { + r.Get("/models", h.getAgentLLMModels) + r.Get("/v1/models", h.getAgentLLMModels) + r.Post("/chat/completions", h.createAgentLLMChatCompletions) + r.Post("/v1/chat/completions", h.createAgentLLMChatCompletions) + r.Post("/responses", h.createAgentLLMResponses) + r.Post("/v1/responses", h.createAgentLLMResponses) + r.Get("/responses", h.getAgentLLMResponsesWebsocket) + r.Get("/v1/responses", h.getAgentLLMResponsesWebsocket) + }) r.Post("/recreate", h.recreateAgent) r.Post("/upgrade", h.upgradeAgent) }) @@ -76,11 +85,6 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { }) r.Post("/invite", h.createIMRoomMembersInvite) }) - r.Route("/users", func(r chi.Router) { - r.Get("/", h.listUsers) - r.Post("/", h.createUser) - r.Delete("/{id}", h.deleteUser) - }) r.Route("/messages", func(r chi.Router) { r.Get("/", h.listMessages) r.Post("/", h.createMessage) @@ -117,23 +121,21 @@ func (h *Handler) registerCoreRoutes(router chi.Router) { func (h *Handler) registerChannelRoutes(router chi.Router) { router.Route("/api/v1/channels", func(r chi.Router) { - // Generic bot CRUD for all channels. - r.Route("/{channel}/bots", func(r chi.Router) { - r.Get("/", h.listBots) - r.Post("/", h.createBot) - }) - r.Route("/{channel}/bots/{id}", func(r chi.Router) { - r.Get("/", h.handleBotByID) - r.Patch("/", h.handleBotByID) - r.Delete("/", h.deleteBot) + r.Route("/{channel}/participants", func(r chi.Router) { + r.Get("/", h.listParticipants) + r.Post("/", h.createParticipant) + }) + r.Route("/{channel}/participants/{id}", func(r chi.Router) { + r.Get("/", h.handleParticipantByID) + r.Patch("/", h.handleParticipantByID) + r.Delete("/", h.handleParticipantByID) + r.Get("/events", h.getParticipantEvents) + r.Post("/messages", h.createParticipantMessage) + r.Post("/notifications", h.createParticipantNotification) }) r.Post("/{channel}/activities/{activity_id}:decide", h.handleChannelActivityDecision) - // Channel-specific bot operations (not exposed on generic /{channel}/bots/{id}). - r.Post("/csgclaw/bots/{id}/notifications", h.pushNotificationBot) - r.Get("/feishu/bots/{id}/events", h.getFeishuBotEvents) - - // CSGClaw channel IM routes (flat paths so /csgclaw/bots stays on generic CRUD). + // CSGClaw channel IM routes. r.Route("/csgclaw/users", func(r chi.Router) { r.Get("/", h.listUsers) r.Post("/", h.createUser) @@ -159,7 +161,7 @@ func (h *Handler) registerChannelRoutes(router chi.Router) { r.Post("/", h.createMessage) }) - // Feishu channel routes (flat paths; bot list/CRUD uses generic /{channel}/bots). + // Feishu channel routes. r.Route("/feishu/config", func(r chi.Router) { r.Get("/", h.getFeishuConfig) r.Put("/", h.updateFeishuConfig) diff --git a/internal/apiclient/client.go b/internal/apiclient/client.go index 9b1431da..08049cbc 100644 --- a/internal/apiclient/client.go +++ b/internal/apiclient/client.go @@ -13,6 +13,7 @@ import ( "csgclaw/internal/apitypes" "csgclaw/internal/config" + "csgclaw/internal/participant" ) type HTTPClient interface { @@ -43,45 +44,50 @@ func New(endpoint, token string, client HTTPClient) *Client { } } -func (c *Client) ListBots(ctx context.Context, channel, role, botType string) ([]apitypes.Bot, error) { - var bots []apitypes.Bot +func (c *Client) ListParticipants(ctx context.Context, channel, typ, agentID string) ([]apitypes.Participant, error) { + var participants []apitypes.Participant values := url.Values{} - if strings.TrimSpace(role) != "" { - values.Set("role", strings.TrimSpace(role)) + if strings.TrimSpace(typ) != "" { + values.Set("type", strings.TrimSpace(typ)) } - if strings.TrimSpace(botType) != "" { - values.Set("type", strings.TrimSpace(botType)) + if strings.TrimSpace(agentID) != "" { + values.Set("agent_id", strings.TrimSpace(agentID)) } - path, err := botCollectionPath(channel) + path, err := participantCollectionPath(channel) if err != nil { return nil, err } if encoded := values.Encode(); encoded != "" { path += "?" + encoded } - if err := c.GetJSON(ctx, path, &bots); err != nil { + if err := c.GetJSON(ctx, path, &participants); err != nil { return nil, err } - return bots, nil + return participants, nil } -func (c *Client) CreateBot(ctx context.Context, req apitypes.CreateBotRequest) (apitypes.Bot, error) { - var created apitypes.Bot - path, err := botCollectionPath(req.Channel) +func (c *Client) CreateParticipant(ctx context.Context, req participant.CreateRequest) (apitypes.Participant, error) { + var created apitypes.Participant + path, err := participantCollectionPath(req.Channel) if err != nil { - return apitypes.Bot{}, err + return apitypes.Participant{}, err } if err := c.DoJSON(ctx, http.MethodPost, path, req, &created); err != nil { - return apitypes.Bot{}, err + return apitypes.Participant{}, err } return created, nil } -func (c *Client) DeleteBot(ctx context.Context, channel, id string) error { - path, err := botItemPath(channel, id) +func (c *Client) DeleteParticipant(ctx context.Context, channel, id, deleteAgent string) error { + path, err := participantItemPath(channel, id) if err != nil { return err } + if strings.TrimSpace(deleteAgent) != "" { + values := url.Values{} + values.Set("delete_agent", strings.TrimSpace(deleteAgent)) + path += "?" + values.Encode() + } return c.DoNoContent(ctx, http.MethodDelete, path) } @@ -191,6 +197,9 @@ func (c *Client) ListUsers(ctx context.Context) ([]apitypes.User, error) { func (c *Client) ListUsersByChannel(ctx context.Context, channel string) ([]apitypes.User, error) { var users []apitypes.User + if strings.TrimSpace(channel) == "" { + channel = "csgclaw" + } path, err := channelPath(channel, "users") if err != nil { return nil, err @@ -203,6 +212,9 @@ func (c *Client) ListUsersByChannel(ctx context.Context, channel string) ([]apit func (c *Client) CreateUser(ctx context.Context, channel string, req apitypes.CreateUserRequest) (apitypes.User, error) { var created apitypes.User + if strings.TrimSpace(channel) == "" { + channel = "csgclaw" + } path, err := channelPath(channel, "users") if err != nil { return apitypes.User{}, err @@ -488,29 +500,29 @@ func channelPath(channelName, resource string) (string, error) { } } -func botCollectionPath(channelName string) (string, error) { +func participantCollectionPath(channelName string) (string, error) { channelName = strings.ToLower(strings.TrimSpace(channelName)) if channelName == "" { channelName = "csgclaw" } switch channelName { case "csgclaw", "feishu": - return "/api/v1/channels/" + channelName + "/bots", nil + return "/api/v1/channels/" + channelName + "/participants", nil default: return "", fmt.Errorf("unsupported channel %q", channelName) } } -func botItemPath(channelName, botID string) (string, error) { - path, err := botCollectionPath(channelName) +func participantItemPath(channelName, participantID string) (string, error) { + path, err := participantCollectionPath(channelName) if err != nil { return "", err } - botID = strings.TrimSpace(botID) - if botID == "" { - return "", fmt.Errorf("bot id is required") + participantID = strings.TrimSpace(participantID) + if participantID == "" { + return "", fmt.Errorf("participant id is required") } - return path + "/" + url.PathEscape(botID), nil + return path + "/" + url.PathEscape(participantID), nil } func memberCreatePath(channelName, roomID string) (string, error) { @@ -570,7 +582,7 @@ func userDeletePath(channelName, userID string) (string, error) { } switch channelName { case "": - return "/api/v1/users/" + url.PathEscape(userID), nil + return "/api/v1/channels/csgclaw/users/" + url.PathEscape(userID), nil case "csgclaw": return "/api/v1/channels/csgclaw/users/" + url.PathEscape(userID), nil case "feishu": diff --git a/internal/apiclient/notification_bots.go b/internal/apiclient/notification_bots.go deleted file mode 100644 index b33cca8f..00000000 --- a/internal/apiclient/notification_bots.go +++ /dev/null @@ -1,37 +0,0 @@ -package apiclient - -import ( - "context" - "net/http" - - "csgclaw/internal/apitypes" -) - -func (c *Client) CreateNotificationBot(ctx context.Context, req apitypes.CreateBotRequest) (apitypes.Bot, error) { - var created apitypes.Bot - path, err := botCollectionPath(req.Channel) - if err != nil { - return apitypes.Bot{}, err - } - req.Type = "notification" - if err := c.DoJSON(ctx, http.MethodPost, path, req, &created); err != nil { - return apitypes.Bot{}, err - } - return created, nil -} - -func (c *Client) PatchNotificationBot(ctx context.Context, channel, id string, req apitypes.PatchNotificationBotRequest) (apitypes.Bot, error) { - var updated apitypes.Bot - path, err := botItemPath(channel, id) - if err != nil { - return apitypes.Bot{}, err - } - if err := c.DoJSON(ctx, http.MethodPatch, path, req, &updated); err != nil { - return apitypes.Bot{}, err - } - return updated, nil -} - -func (c *Client) DeleteNotificationBot(ctx context.Context, channel, id string) error { - return c.DeleteBot(ctx, channel, id) -} diff --git a/internal/apitypes/participant.go b/internal/apitypes/participant.go new file mode 100644 index 00000000..de9d09e1 --- /dev/null +++ b/internal/apitypes/participant.go @@ -0,0 +1,26 @@ +package apitypes + +import "time" + +type Participant struct { + ID string `json:"id"` + Channel string `json:"channel"` + Type string `json:"type"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + ChannelUserRef string `json:"channel_user_ref,omitempty"` + ChannelUserKind string `json:"channel_user_kind,omitempty"` + ChannelAppRef string `json:"channel_app_ref,omitempty"` + AgentID string `json:"agent_id,omitempty"` + LifecycleStatus string `json:"lifecycle_status"` + Presence string `json:"presence,omitempty"` + Mentionable bool `json:"mentionable"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ParticipantRef struct { + Channel string `json:"channel,omitempty"` + ID string `json:"id"` +} diff --git a/internal/app/channelwiring/notification_bot.go b/internal/app/channelwiring/notification_bot.go index bd59b14f..6718b8df 100644 --- a/internal/app/channelwiring/notification_bot.go +++ b/internal/app/channelwiring/notification_bot.go @@ -2,23 +2,28 @@ package channelwiring import ( "context" + "strings" "csgclaw/internal/bot" "csgclaw/internal/channel/csgclaw/notification_bot" notificationpull "csgclaw/internal/channel/csgclaw/notification_bot/pull" "csgclaw/internal/im" + "csgclaw/internal/participant" ) // WireNotificationBotPull starts the pull supervisor for notification bots and returns the fanout deliverer. -func WireNotificationBotPull(ctx context.Context, botSvc *bot.Service, imSvc *im.Service, apiBaseURL, accessToken string) notification_bot.Fanouter { - if botSvc == nil { +func WireNotificationBotPull(ctx context.Context, botSvc *bot.Service, participantSvc *participant.Service, imSvc *im.Service, apiBaseURL, accessToken string) notification_bot.Fanouter { + if botSvc == nil && participantSvc == nil { return nil } deliver := NewNotificationDeliver(imSvc, apiBaseURL, accessToken) if deliver == nil { return nil } - go notificationpull.NewSupervisor(botSvc, deliver).Run(ctx) + go notificationpull.NewSupervisor(notificationPullSource{ + botSvc: botSvc, + participant: participantSvc, + }, deliver).Run(ctx) return deliver } @@ -29,3 +34,79 @@ func NewNotificationDeliver(imSvc *im.Service, apiBaseURL, accessToken string) * } return notification_bot.NewAPIDeliver(imSvc, apiBaseURL, accessToken) } + +type notificationPullSource struct { + botSvc *bot.Service + participant *participant.Service +} + +func (s notificationPullSource) Reload() error { + if s.botSvc != nil { + return s.botSvc.Reload() + } + return nil +} + +func (s notificationPullSource) ListNotificationBots(channel string) ([]bot.Bot, error) { + seen := map[string]struct{}{} + out := make([]bot.Bot, 0) + if s.participant != nil { + for _, item := range s.participant.List(participant.ListOptions{ + Channel: channel, + Type: participant.TypeNotification, + }) { + id := strings.TrimSpace(item.ID) + if id == "" { + continue + } + seen[id] = struct{}{} + out = append(out, notificationParticipantAsBot(item)) + } + } + if s.botSvc != nil { + bots, err := s.botSvc.ListNotificationBots(channel) + if err != nil { + return nil, err + } + for _, b := range bots { + id := strings.TrimSpace(b.ID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + out = append(out, b) + } + } + return out, nil +} + +func (s notificationPullSource) LookupNotificationBotForDelivery(channel, id string) (map[string]any, string, bool) { + channel = strings.TrimSpace(channel) + id = strings.TrimSpace(id) + if s.participant != nil { + item, ok := s.participant.Get(channel, id) + if ok && strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeNotification) { + return item.Metadata, item.ChannelUserRef, true + } + } + if s.botSvc != nil { + return s.botSvc.LookupNotificationBotForDelivery(channel, id) + } + return nil, "", false +} + +func notificationParticipantAsBot(item participant.Participant) bot.Bot { + return bot.Bot{ + ID: item.ID, + Name: item.Name, + Type: bot.BotTypeNotification, + Role: string(bot.RoleWorker), + Channel: item.Channel, + UserID: item.ChannelUserRef, + RuntimeOptions: item.Metadata, + Available: true, + CreatedAt: item.CreatedAt, + } +} diff --git a/internal/app/channelwiring/notification_bot_test.go b/internal/app/channelwiring/notification_bot_test.go new file mode 100644 index 00000000..8151119f --- /dev/null +++ b/internal/app/channelwiring/notification_bot_test.go @@ -0,0 +1,47 @@ +package channelwiring + +import ( + "testing" + "time" + + "csgclaw/internal/apitypes" + "csgclaw/internal/bot" + "csgclaw/internal/participant" +) + +func TestNotificationPullSourceUsesNotificationParticipants(t *testing.T) { + participantSvc := participant.NewService(participant.NewMemoryStore([]apitypes.Participant{ + { + ID: "alerts", + Channel: participant.ChannelCSGClaw, + Type: participant.TypeNotification, + Name: "Alerts", + ChannelUserRef: "n-alerts", + ChannelUserKind: participant.ChannelUserKindLocalUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{ + "delivery_mode": "pull", + "remote_token": "secret-token", + }, + CreatedAt: time.Date(2026, 6, 5, 8, 0, 0, 0, time.UTC), + }, + })) + source := notificationPullSource{participant: participantSvc} + + bots, err := source.ListNotificationBots(string(bot.ChannelCSGClaw)) + if err != nil { + t.Fatalf("ListNotificationBots() error = %v", err) + } + if len(bots) != 1 || bots[0].ID != "alerts" || bots[0].UserID != "n-alerts" { + t.Fatalf("bots = %+v, want participant-backed alerts notification", bots) + } + + metadata, userID, ok := source.LookupNotificationBotForDelivery(string(bot.ChannelCSGClaw), "alerts") + if !ok { + t.Fatal("LookupNotificationBotForDelivery() ok = false, want true") + } + if userID != "n-alerts" || metadata["remote_token"] != "secret-token" { + t.Fatalf("lookup metadata=%#v userID=%q, want stored participant delivery config", metadata, userID) + } +} diff --git a/internal/app/runtimewiring/openclaw.go b/internal/app/runtimewiring/openclaw.go index aff8a33e..98f10b0c 100644 --- a/internal/app/runtimewiring/openclaw.go +++ b/internal/app/runtimewiring/openclaw.go @@ -26,10 +26,10 @@ func UpdateOpenClawFeishuProvider(svc *agent.Service, provider feishu.BotCredent updateRuntimeFeishuProvider(svc, agentruntime.KindOpenClawSandbox, provider) } -func openClawBoxEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, _ feishu.BotCredentialProvider) map[string]string { +func openClawBoxEnvVars(baseURL, accessToken, participantID, _ string, llmBaseURL, modelID string, _ feishu.BotCredentialProvider) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken - env["CSGCLAW_BOT_ID"] = botID + env["CSGCLAW_BOT_ID"] = participantID return env } diff --git a/internal/app/runtimewiring/picoclaw.go b/internal/app/runtimewiring/picoclaw.go index 416b72c4..56a6c21c 100644 --- a/internal/app/runtimewiring/picoclaw.go +++ b/internal/app/runtimewiring/picoclaw.go @@ -27,20 +27,21 @@ func UpdatePicoClawFeishuProvider(svc *agent.Service, provider feishu.BotCredent updateRuntimeFeishuProvider(svc, agentruntime.KindPicoClawSandbox, provider) } -func picoClawRuntimeEnvVars(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { +func picoClawRuntimeEnvVars(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string { env := bridgeLLMEnvVars(llmBaseURL, accessToken, modelID) picoclawModelID := picoclawBridgeModelID(modelID) env["CSGCLAW_BASE_URL"] = baseURL env["CSGCLAW_ACCESS_TOKEN"] = accessToken + env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"] = "true" env["PICOCLAW_CHANNELS_CSGCLAW_BASE_URL"] = baseURL env["PICOCLAW_CHANNELS_CSGCLAW_ACCESS_TOKEN"] = accessToken - env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"] = botID + env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"] = participantID env["PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_NAME"] = modelID env["PICOCLAW_CUSTOM_MODEL_ID"] = picoclawModelID env["PICOCLAW_CUSTOM_MODEL_API_KEY"] = accessToken env["PICOCLAW_CUSTOM_MODEL_BASE_URL"] = llmBaseURL - addFeishuBoxEnvVars(env, botID, provider) + addFeishuBoxEnvVars(env, agentID, provider) return env } @@ -56,8 +57,14 @@ func addFeishuBoxEnvVars(envVars map[string]string, botID string, provider feish if !ok { return } - envVars["PICOCLAW_CHANNELS_FEISHU_APP_ID"] = app.AppID - envVars["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"] = app.AppSecret + appID := strings.TrimSpace(app.AppID) + appSecret := strings.TrimSpace(app.AppSecret) + if appID == "" || appSecret == "" { + return + } + envVars["PICOCLAW_CHANNELS_FEISHU_ENABLED"] = "true" + envVars["PICOCLAW_CHANNELS_FEISHU_APP_ID"] = appID + envVars["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"] = appSecret } func picoclawBridgeModelID(modelID string) string { diff --git a/internal/app/runtimewiring/picoclaw_test.go b/internal/app/runtimewiring/picoclaw_test.go new file mode 100644 index 00000000..e5ef3096 --- /dev/null +++ b/internal/app/runtimewiring/picoclaw_test.go @@ -0,0 +1,88 @@ +package runtimewiring + +import ( + "testing" + + "csgclaw/internal/channel/feishu" +) + +func TestPicoClawRuntimeEnvVarsUseParticipantIDForCSGClawChannel(t *testing.T) { + env := picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "manager", + "u-manager", + "http://10.0.0.8:18080/api/v1/agents/u-manager/llm", + "minimax-m2.7", + nil, + ) + + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"], "manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID = %q, want %q", got, want) + } + if _, ok := env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_ENABLED"], "true"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_ENABLED = %q, want %q", got, want) + } +} + +func TestPicoClawRuntimeEnvVarsEnableFeishuOnlyForConfiguredBot(t *testing.T) { + env := picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "u-missing", + "u-missing", + "http://10.0.0.8:18080/api/v1/agents/u-missing/llm", + "minimax-m2.7", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + }}, + ) + for _, key := range []string{ + "PICOCLAW_CHANNELS_FEISHU_ENABLED", + "PICOCLAW_CHANNELS_FEISHU_APP_ID", + "PICOCLAW_CHANNELS_FEISHU_APP_SECRET", + } { + if _, ok := env[key]; ok { + t.Fatalf("%s should not be emitted for an unconfigured Feishu bot", key) + } + } + + env = picoClawRuntimeEnvVars( + "http://10.0.0.8:18080", + "shared-token", + "manager", + "u-manager", + "http://10.0.0.8:18080/api/v1/agents/u-manager/llm", + "minimax-m2.7", + staticFeishuProvider{apps: map[string]feishu.AppConfig{ + "u-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + }}, + ) + if got, want := env["PICOCLAW_CHANNELS_FEISHU_ENABLED"], "true"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_ENABLED = %q, want %q", got, want) + } + if got, want := env["PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID"], "manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_PARTICIPANT_ID = %q, want %q", got, want) + } + if _, ok := env["PICOCLAW_CHANNELS_CSGCLAW_BOT_ID"]; ok { + t.Fatalf("PICOCLAW_CHANNELS_CSGCLAW_BOT_ID should not be emitted") + } + if got, want := env["PICOCLAW_CHANNELS_FEISHU_APP_ID"], "cli_manager"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_APP_ID = %q, want %q", got, want) + } + if got, want := env["PICOCLAW_CHANNELS_FEISHU_APP_SECRET"], "manager-secret"; got != want { + t.Fatalf("PICOCLAW_CHANNELS_FEISHU_APP_SECRET = %q, want %q", got, want) + } +} + +type staticFeishuProvider struct { + apps map[string]feishu.AppConfig +} + +func (p staticFeishuProvider) BotConfig(botID string) (feishu.AppConfig, bool) { + app, ok := p.apps[botID] + return app, ok +} diff --git a/internal/app/runtimewiring/sandbox.go b/internal/app/runtimewiring/sandbox.go index 13a0c670..b5334deb 100644 --- a/internal/app/runtimewiring/sandbox.go +++ b/internal/app/runtimewiring/sandbox.go @@ -12,7 +12,7 @@ import ( "csgclaw/internal/sandbox" ) -type sandboxRuntimeEnvBuilder func(baseURL, accessToken, botID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string +type sandboxRuntimeEnvBuilder func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, provider feishu.BotCredentialProvider) map[string]string func withSandboxRuntimeHost(host agent.PicoClawRuntimeHost, feishuProvider feishu.BotCredentialProvider, buildRuntimeEnv sandboxRuntimeEnvBuilder, newRuntime func(sandboxgateway.Dependencies) agentruntime.Runtime) agent.ServiceOption { return func(s *agent.Service) error { diff --git a/internal/bot/service.go b/internal/bot/service.go index c42d288d..b8182b40 100644 --- a/internal/bot/service.go +++ b/internal/bot/service.go @@ -638,8 +638,12 @@ func (s *Service) ensureChannelUser(ctx context.Context, channelName string, cre if s.imProv == nil { return "", time.Time{}, fmt.Errorf("im provisioner is required") } + userID := created.ID + if strings.TrimSpace(created.ID) == agent.ManagerUserID { + userID = agent.ManagerParticipantID + } result, err := s.imProv.EnsureAgentUser(ctx, im.AgentIdentity{ - ID: created.ID, + ID: userID, Name: created.Name, Description: created.Description, Handle: deriveAgentHandle(created), diff --git a/internal/bot/service_test.go b/internal/bot/service_test.go index 3430a259..5a362d70 100644 --- a/internal/bot/service_test.go +++ b/internal/bot/service_test.go @@ -915,7 +915,7 @@ func TestServiceDeleteKeepsBotRecordWhenChannelUserDeletionFails(t *testing.T) { Role: string(RoleManager), Channel: string(ChannelCSGClaw), AgentID: "u-manager", - UserID: "u-manager", + UserID: agent.ManagerParticipantID, CreatedAt: time.Date(2026, 4, 12, 10, 0, 0, 0, time.UTC), }, }) @@ -923,9 +923,9 @@ func TestServiceDeleteKeepsBotRecordWhenChannelUserDeletionFails(t *testing.T) { t.Fatalf("NewMemoryStore() error = %v", err) } imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", + CurrentUserID: agent.ManagerParticipantID, Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", IsOnline: true}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", IsOnline: true}, }, }) svc, err := NewServiceWithDependencies(store, nil, imSvc) @@ -1412,14 +1412,14 @@ func TestServiceCreateCSGClawManagerBindsBootstrappedAgent(t *testing.T) { if err != nil { t.Fatalf("Create(manager) error = %v", err) } - if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("Create(manager) = %+v, want u-manager IDs", got) + if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerParticipantID { + t.Fatalf("Create(manager) = %+v, want agent %q and channel user %q", got, agent.ManagerUserID, agent.ManagerParticipantID) } if got.Role != string(RoleManager) || got.Channel != string(ChannelCSGClaw) { t.Fatalf("Create(manager) = %+v, want manager csgclaw", got) } - if !containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager", imSvc.ListUsers()) + if !containsUser(imSvc.ListUsers(), agent.ManagerParticipantID) { + t.Fatalf("users = %+v, want %s", imSvc.ListUsers(), agent.ManagerParticipantID) } } @@ -1500,11 +1500,11 @@ func TestServiceCreateCSGClawManagerReusesExistingBotAndRestoresMissingUser(t *t }); err != nil { t.Fatalf("first Create(manager) error = %v", err) } - if err := imSvc.DeleteUser(agent.ManagerUserID); err != nil { + if err := imSvc.DeleteUser(agent.ManagerParticipantID); err != nil { t.Fatalf("DeleteUser(manager) error = %v", err) } - if containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager removed before second create", imSvc.ListUsers()) + if containsUser(imSvc.ListUsers(), agent.ManagerParticipantID) { + t.Fatalf("users = %+v, want %s removed before second create", imSvc.ListUsers(), agent.ManagerParticipantID) } got, err := svc.Create(context.Background(), CreateRequest{ @@ -1516,11 +1516,11 @@ func TestServiceCreateCSGClawManagerReusesExistingBotAndRestoresMissingUser(t *t if err != nil { t.Fatalf("second Create(manager) error = %v", err) } - if got.ID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("second Create(manager) = %+v, want u-manager", got) + if got.ID != agent.ManagerUserID || got.UserID != agent.ManagerParticipantID { + t.Fatalf("second Create(manager) = %+v, want agent %q and channel user %q", got, agent.ManagerUserID, agent.ManagerParticipantID) } - if !containsUser(imSvc.ListUsers(), agent.ManagerUserID) { - t.Fatalf("users = %+v, want u-manager restored", imSvc.ListUsers()) + if !containsUser(imSvc.ListUsers(), agent.ManagerParticipantID) { + t.Fatalf("users = %+v, want %s restored", imSvc.ListUsers(), agent.ManagerParticipantID) } } @@ -1632,8 +1632,8 @@ func TestServiceCreateManagerBootstrapsMissingAgent(t *testing.T) { if err != nil { t.Fatalf("Create(manager) error = %v", err) } - if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerUserID { - t.Fatalf("Create(manager) = %+v, want u-manager IDs", got) + if got.ID != agent.ManagerUserID || got.AgentID != agent.ManagerUserID || got.UserID != agent.ManagerParticipantID { + t.Fatalf("Create(manager) = %+v, want agent %q and channel user %q", got, agent.ManagerUserID, agent.ManagerParticipantID) } if _, ok := agentSvc.Agent(agent.ManagerUserID); !ok { t.Fatal("manager agent was not bootstrapped") diff --git a/internal/channel/codexbridge/bridge_test.go b/internal/channel/codexbridge/bridge_test.go index acccd4e5..c581498b 100644 --- a/internal/channel/codexbridge/bridge_test.go +++ b/internal/channel/codexbridge/bridge_test.go @@ -1112,7 +1112,10 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { BaseURL: "http://example.test", MentionOnly: true, HTTPClient: &http.Client{ - Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got, want := req.URL.Path, "/api/v1/channels/csgclaw/participants/u-codex/events"; got != want { + t.Fatalf("event stream path = %q, want %q", got, want) + } return &http.Response{ StatusCode: http.StatusOK, Header: make(http.Header), @@ -1159,6 +1162,44 @@ func TestHTTPClientStreamEventsMentionOnly(t *testing.T) { } } +func TestHTTPClientSendMessageUsesParticipantRoute(t *testing.T) { + t.Parallel() + + client := &HTTPClient{ + BaseURL: "http://example.test", + Token: "secret", + HTTPClient: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got, want := req.Method, http.MethodPost; got != want { + t.Fatalf("method = %q, want %q", got, want) + } + if got, want := req.URL.Path, "/api/v1/channels/csgclaw/participants/u-codex/messages"; got != want { + t.Fatalf("send message path = %q, want %q", got, want) + } + if got, want := req.Header.Get("Authorization"), "Bearer secret"; got != want { + t.Fatalf("authorization = %q, want %q", got, want) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"message_id":"m-1"}`)), + }, nil + }), + }, + } + + got, err := client.SendMessage(context.Background(), "u-codex", SendMessageRequest{ + RoomID: "room-1", + Text: "hello", + }) + if err != nil { + t.Fatalf("SendMessage() error = %v", err) + } + if got.MessageID != "m-1" { + t.Fatalf("MessageID = %q, want %q", got.MessageID, "m-1") + } +} + type roundTripFunc func(*http.Request) (*http.Response, error) func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { diff --git a/internal/channel/codexbridge/sse_client.go b/internal/channel/codexbridge/sse_client.go index b9505c83..e24d5d98 100644 --- a/internal/channel/codexbridge/sse_client.go +++ b/internal/channel/codexbridge/sse_client.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" ) @@ -73,7 +74,7 @@ func (c *HTTPClient) StreamEvents(ctx context.Context, botID, lastEventID string defer close(events) defer close(errs) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(c.BaseURL, "/")+"/api/bots/"+strings.TrimSpace(botID)+"/events", nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.participantBridgeURL(botID, "/events"), nil) if err != nil { errs <- err return @@ -117,7 +118,7 @@ func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMess if err != nil { return SendMessageResponse{}, fmt.Errorf("marshal send message request: %w", err) } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/api/bots/"+strings.TrimSpace(botID)+"/messages/send", bytes.NewReader(payload)) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.participantBridgeURL(botID, "/messages"), bytes.NewReader(payload)) if err != nil { return SendMessageResponse{}, err } @@ -142,6 +143,14 @@ func (c *HTTPClient) SendMessage(ctx context.Context, botID string, req SendMess return sendResp, nil } +func (c *HTTPClient) participantBridgeURL(participantID, suffix string) string { + baseURL := "" + if c != nil { + baseURL = c.BaseURL + } + return strings.TrimRight(baseURL, "/") + "/api/v1/channels/csgclaw/participants/" + url.PathEscape(strings.TrimSpace(participantID)) + suffix +} + func (c *HTTPClient) httpClient() *http.Client { if c != nil && c.HTTPClient != nil { return c.HTTPClient diff --git a/internal/channel/csgclaw/service_test.go b/internal/channel/csgclaw/service_test.go index e15f9ec6..9a22ab20 100644 --- a/internal/channel/csgclaw/service_test.go +++ b/internal/channel/csgclaw/service_test.go @@ -16,9 +16,9 @@ func TestNewServiceWithNilIMReturnsNil(t *testing.T) { func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", + CurrentUserID: "manager", Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice", Role: "worker"}, {ID: "u-bob", Name: "bob", Handle: "bob", Role: "worker"}, }, @@ -27,7 +27,7 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { room, err := svc.CreateRoom(apitypes.CreateRoomRequest{ Title: "Ops", - CreatorID: " u-manager ", + CreatorID: " manager ", MemberIDs: []string{ " u-alice ", }, @@ -35,11 +35,11 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { if err != nil { t.Fatalf("CreateRoom() error = %v", err) } - assertMembers(t, room.Members, "u-manager", "u-alice") + assertMembers(t, room.Members, "manager", "u-alice") room, err = svc.AddRoomMembers(apitypes.AddRoomMembersRequest{ RoomID: room.ID, - InviterID: " u-manager ", + InviterID: " manager ", UserIDs: []string{ " u-bob ", }, @@ -47,19 +47,19 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { if err != nil { t.Fatalf("AddRoomMembers() error = %v", err) } - assertMembers(t, room.Members, "u-manager", "u-alice", "u-bob") + assertMembers(t, room.Members, "manager", "u-alice", "u-bob") message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: room.ID, - SenderID: " u-manager ", + SenderID: " manager ", MentionID: " u-alice ", Content: "hello", }) if err != nil { t.Fatalf("SendMessage() error = %v", err) } - if message.SenderID != "u-manager" { - t.Fatalf("SenderID = %q, want %q", message.SenderID, "u-manager") + if message.SenderID != "manager" { + t.Fatalf("SenderID = %q, want %q", message.SenderID, "manager") } if !strings.Contains(message.Content, "u-alice") { t.Fatalf("Content = %q, want mention tag for u-alice", message.Content) @@ -91,18 +91,18 @@ func TestServiceUsesBotIDsAsIMUserIDs(t *testing.T) { func TestServiceNormalizesCanonicalSlashCommand(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", + CurrentUserID: "manager", Users: []im.User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice", Role: "worker"}, }, - Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"u-manager", "u-alice"}}}, + Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"manager", "u-alice"}}}, }) svc := NewService(imSvc) message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: ` create one `, }) if err != nil { @@ -116,15 +116,15 @@ func TestServiceNormalizesCanonicalSlashCommand(t *testing.T) { func TestServiceKeepsLegacySlashTextAsPlainContent(t *testing.T) { imSvc := im.NewServiceFromBootstrap(im.Bootstrap{ - CurrentUserID: "u-manager", - Users: []im.User{{ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}}, - Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"u-manager"}}}, + CurrentUserID: "manager", + Users: []im.User{{ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}}, + Rooms: []im.Room{{ID: "room-1", Title: "Direct", Members: []string{"manager"}}}, }) svc := NewService(imSvc) message, err := svc.SendMessage(apitypes.CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: `/skill-creator create one`, }) if err != nil { diff --git a/internal/im/deliver_message_bus_test.go b/internal/im/deliver_message_bus_test.go index 04606c8a..af4d18e7 100644 --- a/internal/im/deliver_message_bus_test.go +++ b/internal/im/deliver_message_bus_test.go @@ -10,13 +10,13 @@ func TestDeliverMessagePublishesMessageCreatedEvent(t *testing.T) { svc := NewServiceFromBootstrapWithBus(Bootstrap{ CurrentUserID: "u-admin", Users: []User{ - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, {ID: "u-p-w-0604", Name: "worker", Handle: "p-w-0604", Role: "worker"}, }, Rooms: []Room{{ ID: "room-1", Title: "task room", - Members: []string{"u-manager", "u-p-w-0604"}, + Members: []string{"manager", "u-p-w-0604"}, }}, }, bus) @@ -25,7 +25,7 @@ func TestDeliverMessagePublishesMessageCreatedEvent(t *testing.T) { _, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MentionID: "u-p-w-0604", Content: "[team] Task task-17 is ready for you", }) diff --git a/internal/im/service.go b/internal/im/service.go index bcc0aa40..6b637163 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -158,7 +158,12 @@ func HasMentionTagForUser(content, userID string) bool { return false } -const sessionsDirName = "sessions" +const ( + sessionsDirName = "sessions" + adminUserID = "u-admin" + managerParticipantUserID = "manager" + legacyManagerUserID = "u-manager" +) type persistedBootstrap struct { CurrentUserID string `json:"current_user_id"` @@ -509,10 +514,14 @@ func normalizeBootstrap(state Bootstrap) Bootstrap { if state.CurrentUserID == "" { state.CurrentUserID = DefaultBootstrap().CurrentUserID } + managerAliases := managerUserAliases(state.Users) state.Users = ensureUsers(state.Users) - state.Rooms = cloneRooms(state.Rooms) + state.Rooms = migrateLegacyManagerRoomRefs(cloneRooms(state.Rooms), managerAliases) if !containsUserID(state.Users, state.CurrentUserID) { - state.CurrentUserID = defaultCurrentUserID(state.Users) + state.CurrentUserID = migrateLegacyManagerID(state.CurrentUserID, managerAliases) + if !containsUserID(state.Users, state.CurrentUserID) { + state.CurrentUserID = defaultCurrentUserID(state.Users) + } } return state } @@ -524,7 +533,7 @@ func ensureUsers(users []User) []User { } if !hasUserHandle(result, "admin") { result = append(result, User{ - ID: "u-admin", + ID: adminUserID, Name: "admin", Handle: "admin", Role: "admin", @@ -542,7 +551,7 @@ func ensureUsers(users []User) []User { } if !hasUserHandle(result, "manager") { result = append(result, User{ - ID: "u-manager", + ID: managerParticipantUserID, Name: "manager", Handle: "manager", Role: "manager", @@ -553,14 +562,38 @@ func ensureUsers(users []User) []User { } else { for i := range result { if strings.EqualFold(strings.TrimSpace(result[i].Handle), "manager") { + result[i].ID = managerParticipantUserID result[i].Name = "manager" result[i].Role = "manager" } } } + result = dropLegacyManagerUserDuplicates(result) return result } +func dropLegacyManagerUserDuplicates(users []User) []User { + out := make([]User, 0, len(users)) + seen := make(map[string]struct{}, len(users)) + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" || id == legacyManagerUserID { + if strings.EqualFold(strings.TrimSpace(user.Handle), "manager") || + strings.EqualFold(strings.TrimSpace(user.Name), "manager") || + strings.EqualFold(strings.TrimSpace(user.Role), "manager") { + id = managerParticipantUserID + user.ID = managerParticipantUserID + } + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, user) + } + return out +} + func normalizeUser(user User) User { user.Name = strings.ToLower(strings.TrimSpace(user.Name)) user.Handle = strings.ToLower(strings.TrimSpace(user.Handle)) @@ -587,7 +620,7 @@ func containsUserID(users []User, userID string) bool { } func defaultCurrentUserID(users []User) string { - for _, preferred := range []string{"u-admin", "u-manager"} { + for _, preferred := range []string{adminUserID, managerParticipantUserID} { if containsUserID(users, preferred) { return preferred } @@ -598,6 +631,25 @@ func defaultCurrentUserID(users []User) string { return "" } +func managerUserAliases(users []User) map[string]struct{} { + aliases := map[string]struct{}{ + legacyManagerUserID: {}, + managerParticipantUserID: {}, + } + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" { + continue + } + if strings.EqualFold(strings.TrimSpace(user.Handle), "manager") || + strings.EqualFold(strings.TrimSpace(user.Name), "manager") || + strings.EqualFold(strings.TrimSpace(user.Role), "manager") { + aliases[id] = struct{}{} + } + } + return aliases +} + func cloneRooms(rooms []Room) []Room { cloned := make([]Room, 0, len(rooms)) for _, room := range rooms { @@ -606,9 +658,61 @@ func cloneRooms(rooms []Room) []Room { return cloned } +func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct{}) []Room { + for i := range rooms { + rooms[i].Members = migrateLegacyManagerIDs(rooms[i].Members, managerAliases) + for j := range rooms[i].Messages { + rooms[i].Messages[j].SenderID = migrateLegacyManagerID(rooms[i].Messages[j].SenderID, managerAliases) + rooms[i].Messages[j].Content = migrateLegacyManagerMentionTags(rooms[i].Messages[j].Content, managerAliases) + for k := range rooms[i].Messages[j].Mentions { + rooms[i].Messages[j].Mentions[k].ID = migrateLegacyManagerID(rooms[i].Messages[j].Mentions[k].ID, managerAliases) + } + } + } + return rooms +} + +func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = migrateLegacyManagerID(id, managerAliases) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + +func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) string { + id = strings.TrimSpace(id) + if _, ok := managerAliases[id]; ok { + return managerParticipantUserID + } + return id +} + +func migrateLegacyManagerMentionTags(content string, managerAliases map[string]struct{}) string { + for id := range managerAliases { + if id == managerParticipantUserID { + continue + } + content = strings.ReplaceAll(content, `user_id="`+id+`"`, `user_id="`+managerParticipantUserID+`"`) + } + return content +} + func ensureAdminManagerRoom(rooms []Room) []Room { for _, room := range rooms { - if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(room, "u-admin") && containsUserIDInRoom(room, "u-manager") { + if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(room, adminUserID) && containsUserIDInRoom(room, managerParticipantUserID) { normalized := room if normalized.Title == "Admin & Manager" { normalized.Title = "admin & manager" @@ -638,11 +742,11 @@ func ensureAdminManagerRoom(rooms []Room) []Room { Subtitle: formatConversationSubtitle(2), Description: "Bootstrap room for admin and manager.", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{adminUserID, managerParticipantUserID}, Messages: []Message{ { ID: fmt.Sprintf("msg-%d", now.UnixNano()+1), - SenderID: "u-manager", + SenderID: managerParticipantUserID, Content: "Bootstrap room created for admin and manager.", CreatedAt: now, }, diff --git a/internal/im/service_test.go b/internal/im/service_test.go index b7bf992b..be69655f 100644 --- a/internal/im/service_test.go +++ b/internal/im/service_test.go @@ -93,7 +93,7 @@ func TestAddAgentToRoomSupportsRoomID(t *testing.T) { room, err := svc.CreateRoom(CreateRoomRequest{ Title: "Ops", CreatorID: "u-admin", - MemberIDs: []string{"u-manager"}, + MemberIDs: []string{"manager"}, }) if err != nil { t.Fatalf("CreateRoom() error = %v", err) @@ -128,7 +128,7 @@ func TestCreateRoomStoresStructuredEvent(t *testing.T) { room, err := svc.CreateRoom(CreateRoomRequest{ Title: "Ops", CreatorID: "u-admin", - MemberIDs: []string{"u-manager"}, + MemberIDs: []string{"manager"}, Locale: "en", }) if err != nil { @@ -155,10 +155,10 @@ func TestCreateMessagePrefixesMentionTag(t *testing.T) { Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, {ID: "u-dev", Name: "dev", Handle: "dev"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ - {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-dev", "u-manager"}}, + {ID: "room-1", Title: "Ops", Members: []string{"u-admin", "u-dev", "manager"}}, }, }) @@ -233,17 +233,17 @@ func TestCreateMessageWithMissingMentionIDFails(t *testing.T) { func TestDeliverMessageReplacesExistingMessageWithSameIDAndSender(t *testing.T) { svc := NewServiceFromBootstrap(Bootstrap{ CurrentUserID: "u-admin", - Users: []User{{ID: "u-manager", Name: "manager", Handle: "manager"}}, + Users: []User{{ID: "manager", Name: "manager", Handle: "manager"}}, Rooms: []Room{{ ID: "room-1", Title: "Ops", - Members: []string{"u-manager"}, + Members: []string{"manager"}, }}, }) first, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MessageID: "act-1", Content: "pending", }) @@ -252,7 +252,7 @@ func TestDeliverMessageReplacesExistingMessageWithSameIDAndSender(t *testing.T) } second, err := svc.DeliverMessage(DeliverMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", MessageID: "act-1", Content: "allowed", }) @@ -333,7 +333,7 @@ func TestDeleteRoomRemovesRoom(t *testing.T) { svc := NewServiceFromBootstrap(Bootstrap{ CurrentUserID: "u-admin", Rooms: []Room{ - {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "u-manager"}}, + {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "manager"}}, }, }) @@ -642,13 +642,13 @@ func TestSaveBootstrapSplitsRoomMessagesIntoSessionFiles(t *testing.T) { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ { ID: "room-1775709078753586000", Title: "0409-1231", - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, Messages: []Message{ { ID: "msg-1775709078753589000", @@ -718,14 +718,14 @@ func TestLoadBootstrapSupportsExternalSessionFiles(t *testing.T) { "current_user_id": "u-admin", "users": [ {"id": "u-admin", "name": "admin", "handle": "admin"}, - {"id": "u-manager", "name": "manager", "handle": "manager"} + {"id": "manager", "name": "manager", "handle": "manager"} ], "rooms": [ { "id": "room-1", "title": "alpha", "subtitle": "", - "members": ["u-admin", "u-manager"], + "members": ["u-admin", "manager"], "messages": "sessions/room-1.jsonl" } ] @@ -734,7 +734,7 @@ func TestLoadBootstrapSupportsExternalSessionFiles(t *testing.T) { t.Fatalf("WriteFile(state.json) error = %v", err) } - sessionLine := `{"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":["u-manager"]}` + "\n" + sessionLine := `{"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":["manager"]}` + "\n" if err := os.MkdirAll(filepath.Join(dir, "sessions"), 0o755); err != nil { t.Fatalf("MkdirAll(sessions) error = %v", err) } @@ -762,14 +762,14 @@ func TestLoadBootstrapRejectsLegacyInlineMessages(t *testing.T) { "current_user_id": "u-admin", "users": [ {"id": "u-admin", "name": "admin", "handle": "admin"}, - {"id": "u-manager", "name": "manager", "handle": "manager"} + {"id": "manager", "name": "manager", "handle": "manager"} ], "rooms": [ { "id": "room-1", "title": "alpha", "subtitle": "", - "members": ["u-admin", "u-manager"], + "members": ["u-admin", "manager"], "messages": [ {"id":"msg-1","sender_id":"u-admin","kind":"message","content":"hello","created_at":"2026-04-09T04:31:18.753589Z","mentions":null} ] @@ -797,7 +797,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, {ID: "u-alice", Name: "alice", Handle: "alice"}, }, Rooms: []Room{ @@ -806,7 +806,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing Title: "ops", IsDirect: false, Description: "group room", - Members: []string{"u-admin", "u-manager", "u-alice"}, + Members: []string{"u-admin", "manager", "u-alice"}, }, }, } @@ -830,7 +830,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing var dm *Room for i := range loaded.Rooms { room := &loaded.Rooms[i] - if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "u-manager") { + if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "manager") { dm = room break } @@ -843,6 +843,62 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing } } +func TestEnsureBootstrapStateMigratesMisspelledManagerReferences(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + legacyID := "man" + "ger" + + state := Bootstrap{ + CurrentUserID: legacyID, + Users: []User{ + {ID: "u-admin", Name: "admin", Handle: "admin"}, + {ID: legacyID, Name: "manager", Handle: "manager", Role: "manager"}, + }, + Rooms: []Room{{ + ID: "room-dm", + Title: "admin & manager", + IsDirect: true, + Members: []string{"u-admin", legacyID}, + Messages: []Message{{ + ID: "msg-1", + SenderID: legacyID, + Content: `manager hello`, + CreatedAt: time.Now().UTC(), + Mentions: []Mention{{ID: legacyID, Name: "manager"}}, + }}, + }}, + } + if err := SaveBootstrap(statePath, state); err != nil { + t.Fatalf("SaveBootstrap() error = %v", err) + } + + if err := EnsureBootstrapState(statePath); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + + loaded, err := LoadBootstrap(statePath) + if err != nil { + t.Fatalf("LoadBootstrap() error = %v", err) + } + if loaded.CurrentUserID != "manager" { + t.Fatalf("CurrentUserID = %q, want manager", loaded.CurrentUserID) + } + if _, ok := NewServiceFromBootstrap(loaded).User(legacyID); ok { + t.Fatalf("legacy manager user %q still exists", legacyID) + } + room := loaded.Rooms[0] + if !containsUserIDInRoom(room, "manager") || containsUserIDInRoom(room, legacyID) { + t.Fatalf("room.Members = %+v, want manager only", room.Members) + } + got := room.Messages[0] + if got.SenderID != "manager" || len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" { + t.Fatalf("message = %+v, want manager sender and mention", got) + } + if !strings.Contains(got.Content, `user_id="manager"`) || strings.Contains(got.Content, legacyID) { + t.Fatalf("message.Content = %q, want manager mention tag", got.Content) + } +} + func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { dir := t.TempDir() statePath := filepath.Join(dir, "state.json") @@ -850,7 +906,7 @@ func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, }, } if err := SaveBootstrap(statePath, initial); err != nil { @@ -873,7 +929,7 @@ func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { ID: "room-1", Title: "admin & manager", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", "manager"}, }, }, } diff --git a/internal/im/service_thread_test.go b/internal/im/service_thread_test.go index 9b0656af..7225a279 100644 --- a/internal/im/service_thread_test.go +++ b/internal/im/service_thread_test.go @@ -98,7 +98,7 @@ func TestCreateThreadReplyHidesFromMainTimelineAndUpdatesSummary(t *testing.T) { reply, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "reply inside thread", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -181,7 +181,7 @@ func TestStartThreadRejectsMissingAndNestedRoots(t *testing.T) { } reply, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "nested candidate", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -211,7 +211,7 @@ func TestThreadStatePersistsAcrossReload(t *testing.T) { } if _, err := svc.CreateMessage(CreateMessageRequest{ RoomID: "room-1", - SenderID: "u-manager", + SenderID: "manager", Content: "persisted reply", RelatesTo: &MessageRelation{ RelType: RelationTypeThread, @@ -320,10 +320,10 @@ func threadTestBootstrap() Bootstrap { CurrentUserID: "u-admin", Users: []User{ {ID: "u-admin", Name: "admin", Handle: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager"}, + {ID: "manager", Name: "manager", Handle: "manager"}, }, Rooms: []Room{ - {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "u-manager"}, Messages: messages}, + {ID: "room-1", Title: "Room One", Members: []string{"u-admin", "manager"}, Messages: messages}, }, } } diff --git a/internal/onboard/detect.go b/internal/onboard/detect.go index 4499a921..5e6e9e43 100644 --- a/internal/onboard/detect.go +++ b/internal/onboard/detect.go @@ -9,16 +9,16 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/app/runtimewiring" - "csgclaw/internal/bot" "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" + "csgclaw/internal/participant" ) var ( - loadIMBootstrap = im.LoadBootstrap - openBotStore = bot.NewStore - openAgentState = func(cfg config.Config, path, managerImage string) (agentStateReader, error) { + loadIMBootstrap = im.LoadBootstrap + openParticipantStore = participant.NewStore + openAgentState = func(cfg config.Config, path, managerImage string) (agentStateReader, error) { return agent.NewServiceWithLLM( effectiveLLMConfig(cfg), cfg.Server, @@ -111,11 +111,11 @@ func DetectState(opts DetectStateOptions) (DetectStateResult, error) { } result.ManagerAgentComplete = managerAgentComplete(agentState) - store, err := openBotStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := openParticipantStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { return DetectStateResult{}, err } - result.ManagerBotComplete = managerBotComplete(store.List()) + result.ManagerBotComplete = managerParticipantComplete(store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw})) return result, nil } @@ -128,14 +128,14 @@ func imBootstrapComplete(state im.Bootstrap) bool { if !hasIMUser(state.Users, "u-admin", "admin", "admin") { return false } - if !hasIMUser(state.Users, "u-manager", "manager", "manager") { + if !hasIMUser(state.Users, agent.ManagerParticipantID, "manager", "manager") { return false } for _, room := range state.Rooms { if room.IsDirect && len(room.Members) == 2 && containsMember(room.Members, "u-admin") && - containsMember(room.Members, "u-manager") { + containsMember(room.Members, agent.ManagerParticipantID) { return true } } @@ -184,21 +184,21 @@ func managerAgentComplete(state agentStateReader) bool { return strings.EqualFold(strings.TrimSpace(managerAgent.Role), agent.RoleManager) } -func managerBotComplete(bots []bot.Bot) bool { - for _, b := range bots { - if strings.TrimSpace(b.Channel) != string(bot.ChannelCSGClaw) { +func managerParticipantComplete(items []participant.Participant) bool { + for _, item := range items { + if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw { continue } - if strings.TrimSpace(b.ID) != agent.ManagerUserID { + if strings.TrimSpace(item.ID) != agent.ManagerParticipantID { continue } - if !strings.EqualFold(strings.TrimSpace(b.Role), string(bot.RoleManager)) { + if !strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeAgent) { return false } - if strings.TrimSpace(b.AgentID) != agent.ManagerUserID { + if strings.TrimSpace(item.AgentID) != agent.ManagerUserID { return false } - return strings.TrimSpace(b.UserID) != "" + return strings.TrimSpace(item.ChannelUserRef) != "" } return false } diff --git a/internal/onboard/detect_test.go b/internal/onboard/detect_test.go index f738a698..96f91c65 100644 --- a/internal/onboard/detect_test.go +++ b/internal/onboard/detect_test.go @@ -11,6 +11,7 @@ import ( "csgclaw/internal/bot" "csgclaw/internal/config" "csgclaw/internal/im" + "csgclaw/internal/participant" ) func TestDetectStateFreshHomeReportsIncompleteBootstrap(t *testing.T) { @@ -60,14 +61,14 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { CurrentUserID: "u-admin", Users: []im.User{ {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"}, - {ID: "u-manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: agent.ManagerParticipantID, Name: "manager", Handle: "manager", Role: "manager"}, }, Rooms: []im.Room{ { ID: "room-bootstrap", Title: "admin & manager", IsDirect: true, - Members: []string{"u-admin", "u-manager"}, + Members: []string{"u-admin", agent.ManagerParticipantID}, }, }, }); err != nil { @@ -101,6 +102,7 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { if !result.Complete() { t.Fatal("Complete() = false, want true") } + assertLegacyBotsMigrated(t) } func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testing.T) { @@ -192,3 +194,31 @@ func writeManagerBotState(t *testing.T, manager bot.Bot) error { } return os.WriteFile(path, append(data, '\n'), 0o600) } + +func assertLegacyBotsMigrated(t *testing.T) { + t.Helper() + + imStatePath, err := config.DefaultIMStatePath() + if err != nil { + t.Fatalf("DefaultIMStatePath() error = %v", err) + } + botsPath := filepath.Join(filepath.Dir(imStatePath), "bots.json") + if _, err := os.Stat(botsPath); !os.IsNotExist(err) { + t.Fatalf("bots.json still exists after participant migration; stat err=%v", err) + } + + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) + if err != nil { + t.Fatalf("participant.NewStore() error = %v", err) + } + got, ok := store.Get(participant.ChannelCSGClaw, agent.ManagerParticipantID) + if !ok { + t.Fatal("manager participant was not created from legacy bots.json") + } + if got.AgentID != agent.ManagerUserID || got.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("manager participant = %+v, want agent %q and channel user %q", got, agent.ManagerUserID, agent.ManagerParticipantID) + } + if _, ok := store.Get(participant.ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("manager participant was migrated under old agent id %q", agent.ManagerUserID) + } +} diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go index 777067f2..add98610 100644 --- a/internal/onboard/onboard.go +++ b/internal/onboard/onboard.go @@ -13,6 +13,7 @@ import ( "csgclaw/internal/config" "csgclaw/internal/hub" "csgclaw/internal/im" + "csgclaw/internal/participant" "csgclaw/internal/sandboxproviders" ) @@ -141,19 +142,29 @@ func createManagerBot(ctx context.Context, agentsPath, imStatePath string, cfg c if err != nil { return bot.Bot{}, err } - store, err := bot.NewStore(filepath.Join(filepath.Dir(imStatePath), "bots.json")) + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) if err != nil { return bot.Bot{}, err } - botSvc, err := bot.NewServiceWithDependencies(store, agentSvc, imSvc) + participantSvc := participant.NewService( + store, + participant.WithAgentService(agentSvc), + participant.WithIMService(imSvc), + ) + created, err := participantSvc.EnsureBootstrapManager(ctx) if err != nil { return bot.Bot{}, err } - return botSvc.CreateManager(ctx, bot.CreateRequest{ - Name: agent.ManagerName, - Role: string(bot.RoleManager), - Channel: string(bot.ChannelCSGClaw), - }, false) + return bot.Bot{ + ID: created.ID, + Name: created.Name, + Role: string(bot.RoleManager), + Channel: created.Channel, + AgentID: created.AgentID, + UserID: created.ChannelUserRef, + Available: true, + CreatedAt: created.CreatedAt, + }, nil } func defaultConfig() config.Config { diff --git a/internal/participant/model.go b/internal/participant/model.go new file mode 100644 index 00000000..92fbfcec --- /dev/null +++ b/internal/participant/model.go @@ -0,0 +1,68 @@ +package participant + +import ( + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +const ( + ChannelCSGClaw = "csgclaw" + ChannelFeishu = "feishu" + + TypeHuman = "human" + TypeAgent = "agent" + TypeNotification = "notification" + + ChannelUserKindLocalUserID = "local_user_id" + ChannelUserKindOpenID = "open_id" + + BindingModeCreate = "create" + BindingModeReuse = "reuse" + BindingModeNone = "none" + + LifecycleStatusActive = "active" +) + +type Participant = apitypes.Participant + +type ChannelUserSpec struct { + Ref string `json:"ref,omitempty"` + Kind string `json:"kind,omitempty"` +} + +type AgentBindingSpec struct { + Mode string `json:"mode,omitempty"` + AgentID string `json:"agent_id,omitempty"` + Agent *agent.CreateAgentSpec `json:"agent,omitempty"` +} + +type CreateRequest struct { + ID string `json:"id,omitempty"` + Channel string `json:"channel,omitempty"` + Type string `json:"type"` + Name string `json:"name"` + Avatar string `json:"avatar,omitempty"` + ChannelAppRef string `json:"channel_app_ref,omitempty"` + ChannelUser ChannelUserSpec `json:"channel_user,omitempty"` + AgentBinding AgentBindingSpec `json:"agent_binding,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type UpdateRequest struct { + Name *string `json:"name,omitempty"` + Avatar *string `json:"avatar,omitempty"` + Mentionable *bool `json:"mentionable,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +type ListOptions struct { + Channel string + Type string + AgentID string +} + +type DeleteOptions struct { + DeleteAgent string +} + +const DeleteAgentIfUnreferenced = "if_unreferenced" diff --git a/internal/participant/service.go b/internal/participant/service.go new file mode 100644 index 00000000..f77eac12 --- /dev/null +++ b/internal/participant/service.go @@ -0,0 +1,569 @@ +package participant + +import ( + "context" + "crypto/rand" + "encoding/base32" + "fmt" + "strings" + "time" + "unicode" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" + "csgclaw/internal/im" +) + +type Service struct { + store *Store + agents *agent.Service + im *im.Service +} + +type Option func(*Service) + +func NewService(store *Store, opts ...Option) *Service { + if store == nil { + store = NewMemoryStore(nil) + } + s := &Service{store: store} + for _, opt := range opts { + if opt != nil { + opt(s) + } + } + return s +} + +func WithAgentService(agentSvc *agent.Service) Option { + return func(s *Service) { + s.agents = agentSvc + } +} + +func WithIMService(imSvc *im.Service) Option { + return func(s *Service) { + s.im = imSvc + } +} + +func (s *Service) List(opts ListOptions) []apitypes.Participant { + if s == nil || s.store == nil { + return nil + } + return s.store.List(opts) +} + +func (s *Service) Get(channel, id string) (apitypes.Participant, bool) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false + } + return s.store.Get(channel, id) +} + +func (s *Service) Create(ctx context.Context, req CreateRequest) (apitypes.Participant, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, fmt.Errorf("participant store is required") + } + normalized, err := s.normalizeCreateRequest(req) + if err != nil { + return apitypes.Participant{}, err + } + if _, ok := s.store.Get(normalized.Channel, normalized.ID); ok { + return apitypes.Participant{}, fmt.Errorf("participant %s:%s already exists", normalized.Channel, normalized.ID) + } + + if normalized.Type == TypeAgent { + agentID, err := s.ensureAgentBinding(ctx, normalized) + if err != nil { + return apitypes.Participant{}, err + } + normalized.AgentID = agentID + } + + if err := s.ensureChannelIdentity(ctx, normalized); err != nil { + return apitypes.Participant{}, err + } + + now := time.Now().UTC() + created := apitypes.Participant{ + ID: normalized.ID, + Channel: normalized.Channel, + Type: normalized.Type, + Name: normalized.Name, + Avatar: normalized.Avatar, + ChannelUserRef: normalized.ChannelUser.Ref, + ChannelUserKind: normalized.ChannelUser.Kind, + ChannelAppRef: normalized.ChannelAppRef, + AgentID: normalized.AgentID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: cloneMetadata(normalized.Metadata), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.store.Save(created); err != nil { + return apitypes.Participant{}, err + } + return created, nil +} + +func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Participant, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, fmt.Errorf("participant store is required") + } + if s.agents == nil { + return apitypes.Participant{}, fmt.Errorf("agent service is required") + } + manager, err := s.agents.EnsureManager(ctx, false) + if err != nil { + return apitypes.Participant{}, err + } + now := time.Now().UTC() + createdAt := manager.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = now + } + existing, ok := s.store.Get(ChannelCSGClaw, agent.ManagerParticipantID) + legacyExisting, legacyOK := s.store.Get(ChannelCSGClaw, agent.ManagerUserID) + legacyItems := s.legacyManagerParticipants() + source := existing + if !ok && legacyOK && isLegacyManagerParticipant(legacyExisting) { + source = legacyExisting + } else if !ok && len(legacyItems) > 0 { + source = legacyItems[0] + } + hasLegacySource := legacyOK && isLegacyManagerParticipant(legacyExisting) || len(legacyItems) > 0 + if (ok || hasLegacySource) && !source.CreatedAt.IsZero() { + createdAt = source.CreatedAt.UTC() + } + + name := strings.TrimSpace(manager.Name) + if name == "" { + name = agent.ManagerName + } + avatar := strings.TrimSpace(manager.Avatar) + metadata := map[string]any(nil) + if ok || hasLegacySource { + metadata = cloneMetadata(source.Metadata) + if avatar == "" { + avatar = strings.TrimSpace(source.Avatar) + } + } + if s.im != nil { + if _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{ + ID: agent.ManagerParticipantID, + Name: name, + Handle: "manager", + Role: agent.RoleManager, + Avatar: avatar, + }); err != nil { + return apitypes.Participant{}, err + } + } + + item := apitypes.Participant{ + ID: agent.ManagerParticipantID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: name, + Avatar: avatar, + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: manager.ID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: now, + } + if err := s.store.Save(item); err != nil { + return apitypes.Participant{}, err + } + if legacyOK && isLegacyManagerParticipant(legacyExisting) { + if _, _, err := s.store.Delete(ChannelCSGClaw, agent.ManagerUserID); err != nil { + return apitypes.Participant{}, err + } + } + for _, legacy := range legacyItems { + if _, _, err := s.store.Delete(ChannelCSGClaw, legacy.ID); err != nil { + return apitypes.Participant{}, err + } + } + return item, nil +} + +func (s *Service) legacyManagerParticipants() []apitypes.Participant { + if s == nil || s.store == nil { + return nil + } + var out []apitypes.Participant + for _, item := range s.store.List(ListOptions{Channel: ChannelCSGClaw, Type: TypeAgent}) { + if strings.TrimSpace(item.ID) == agent.ManagerParticipantID || strings.TrimSpace(item.ID) == agent.ManagerUserID { + continue + } + if strings.TrimSpace(item.AgentID) == agent.ManagerUserID || + strings.TrimSpace(item.ChannelUserRef) == agent.ManagerUserID || + strings.EqualFold(strings.TrimSpace(item.Name), agent.ManagerName) { + out = append(out, item) + } + } + return out +} + +func isLegacyManagerParticipant(item apitypes.Participant) bool { + if strings.TrimSpace(item.ID) != agent.ManagerUserID { + return false + } + if strings.TrimSpace(item.Channel) != ChannelCSGClaw { + return false + } + if strings.TrimSpace(item.AgentID) == agent.ManagerUserID { + return true + } + if strings.TrimSpace(item.ChannelUserRef) == agent.ManagerUserID { + return true + } + return strings.EqualFold(strings.TrimSpace(item.Name), agent.ManagerName) +} + +func (s *Service) Update(_ context.Context, channel, id string, req UpdateRequest) (apitypes.Participant, bool, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = normalizeChannel(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, fmt.Errorf("channel and id are required") + } + item, ok := s.store.Get(channel, id) + if !ok { + return apitypes.Participant{}, false, nil + } + if req.Name != nil { + name := strings.TrimSpace(*req.Name) + if name == "" { + return apitypes.Participant{}, false, fmt.Errorf("name is required") + } + item.Name = name + } + if req.Avatar != nil { + item.Avatar = strings.TrimSpace(*req.Avatar) + } + if req.Mentionable != nil { + item.Mentionable = *req.Mentionable + } + if req.Metadata != nil { + item.Metadata = cloneMetadata(req.Metadata) + } + item.UpdatedAt = time.Now().UTC() + if err := s.store.Save(item); err != nil { + return apitypes.Participant{}, false, err + } + return item, true, nil +} + +func (s *Service) Delete(ctx context.Context, channel, id string, opts DeleteOptions) (apitypes.Participant, bool, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = normalizeChannel(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, fmt.Errorf("channel and id are required") + } + + existing, ok := s.store.Get(channel, id) + if !ok { + return apitypes.Participant{}, false, nil + } + deleteAgentMode := strings.TrimSpace(opts.DeleteAgent) + if deleteAgentMode != "" && deleteAgentMode != DeleteAgentIfUnreferenced { + return apitypes.Participant{}, false, fmt.Errorf("delete_agent must be %q", DeleteAgentIfUnreferenced) + } + if deleteAgentMode == DeleteAgentIfUnreferenced && strings.TrimSpace(existing.AgentID) != "" { + if s.agents == nil { + return apitypes.Participant{}, false, fmt.Errorf("agent service is required") + } + for _, item := range s.store.List(ListOptions{AgentID: existing.AgentID}) { + if item.Channel == existing.Channel && item.ID == existing.ID { + continue + } + return apitypes.Participant{}, false, fmt.Errorf("agent %q is still referenced by participant %s:%s", existing.AgentID, item.Channel, item.ID) + } + } + + deleted, ok, err := s.store.Delete(channel, id) + if err != nil || !ok { + return deleted, ok, err + } + if deleteAgentMode == DeleteAgentIfUnreferenced && strings.TrimSpace(deleted.AgentID) != "" { + if err := s.agents.Delete(ctx, deleted.AgentID); err != nil { + return deleted, true, err + } + } + return deleted, true, nil +} + +type normalizedCreateRequest struct { + ID string + Channel string + Type string + Name string + Avatar string + ChannelAppRef string + ChannelUser ChannelUserSpec + AgentBinding AgentBindingSpec + AgentID string + Metadata map[string]any +} + +func (s *Service) normalizeCreateRequest(req CreateRequest) (normalizedCreateRequest, error) { + channel := normalizeChannel(req.Channel) + if channel == "" { + return normalizedCreateRequest{}, fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) + } + typ := normalizeType(req.Type) + if typ == "" { + return normalizedCreateRequest{}, fmt.Errorf("type must be one of %q, %q, or %q", TypeHuman, TypeAgent, TypeNotification) + } + name := strings.TrimSpace(req.Name) + if name == "" { + return normalizedCreateRequest{}, fmt.Errorf("name is required") + } + + id, err := s.resolveParticipantID(channel, typ, req) + if err != nil { + return normalizedCreateRequest{}, err + } + + channelUser := ChannelUserSpec{ + Ref: strings.TrimSpace(req.ChannelUser.Ref), + Kind: strings.TrimSpace(req.ChannelUser.Kind), + } + if channelUser.Ref == "" && channel == ChannelCSGClaw { + if typ == TypeAgent { + channelUser.Ref = defaultAgentID(id) + } else { + channelUser.Ref = id + } + } + if channelUser.Kind == "" { + switch channel { + case ChannelCSGClaw: + channelUser.Kind = ChannelUserKindLocalUserID + case ChannelFeishu: + channelUser.Kind = ChannelUserKindOpenID + } + } + if channelUser.Ref == "" { + return normalizedCreateRequest{}, fmt.Errorf("channel_user.ref is required") + } + + binding := req.AgentBinding + binding.Mode = normalizeBindingMode(binding.Mode) + binding.AgentID = strings.TrimSpace(binding.AgentID) + if binding.Mode == "" { + binding.Mode = BindingModeNone + } + switch typ { + case TypeHuman, TypeNotification: + if binding.Mode == BindingModeCreate { + return normalizedCreateRequest{}, fmt.Errorf("%s participant cannot create an agent binding", typ) + } + case TypeAgent: + switch binding.Mode { + case BindingModeCreate: + case BindingModeReuse: + if binding.AgentID == "" { + return normalizedCreateRequest{}, fmt.Errorf("agent_binding.agent_id is required for reuse") + } + case BindingModeNone: + default: + return normalizedCreateRequest{}, fmt.Errorf("agent_binding.mode must be one of %q, %q, or %q", BindingModeCreate, BindingModeReuse, BindingModeNone) + } + } + + return normalizedCreateRequest{ + ID: id, + Channel: channel, + Type: typ, + Name: name, + Avatar: strings.TrimSpace(req.Avatar), + ChannelAppRef: strings.TrimSpace(req.ChannelAppRef), + ChannelUser: channelUser, + AgentBinding: binding, + Metadata: cloneMetadata(req.Metadata), + }, nil +} + +func (s *Service) resolveParticipantID(channel, typ string, req CreateRequest) (string, error) { + if id := slugify(req.ID); id != "" { + return id, nil + } + stable := strings.TrimSpace(req.ChannelUser.Ref) + if stable == "" { + stable = strings.TrimSpace(req.AgentBinding.AgentID) + } + if strings.HasPrefix(stable, "u-") && typ == TypeAgent { + stable = strings.TrimPrefix(stable, "u-") + } + if slug := slugify(stable); slug != "" { + if _, ok := s.store.Get(channel, slug); !ok { + return slug, nil + } + return slug + "-" + randomSuffix(), nil + } + return typ + "-" + randomSuffix(), nil +} + +func (s *Service) ensureAgentBinding(ctx context.Context, req normalizedCreateRequest) (string, error) { + switch req.AgentBinding.Mode { + case BindingModeNone: + return "", nil + case BindingModeReuse: + if s.agents == nil { + return "", fmt.Errorf("agent service is required") + } + if _, ok := s.agents.Agent(req.AgentBinding.AgentID); !ok { + return "", fmt.Errorf("agent %q not found", req.AgentBinding.AgentID) + } + return req.AgentBinding.AgentID, nil + case BindingModeCreate: + if s.agents == nil { + return "", fmt.Errorf("agent service is required") + } + agentID := req.AgentBinding.AgentID + if agentID == "" { + agentID = defaultAgentID(req.ID) + } + if existing, ok := s.agents.Agent(agentID); ok { + return existing.ID, nil + } + spec := agent.CreateAgentSpec{} + if req.AgentBinding.Agent != nil { + spec = *req.AgentBinding.Agent + } + spec.ID = agentID + if strings.TrimSpace(spec.Name) == "" { + spec.Name = req.Name + } + if strings.TrimSpace(spec.Role) == "" { + spec.Role = agent.RoleWorker + } + created, err := s.agents.Create(ctx, agent.CreateRequest{Spec: spec}) + if err != nil { + return "", err + } + return created.ID, nil + default: + return "", fmt.Errorf("agent_binding.mode must be one of %q, %q, or %q", BindingModeCreate, BindingModeReuse, BindingModeNone) + } +} + +func (s *Service) ensureChannelIdentity(_ context.Context, req normalizedCreateRequest) error { + if req.Channel != ChannelCSGClaw || s.im == nil { + return nil + } + role := "member" + if req.Type == TypeAgent { + role = agent.RoleWorker + } + _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{ + ID: req.ChannelUser.Ref, + Name: req.Name, + Handle: req.ID, + Role: role, + }) + return err +} + +func normalizeChannel(channel string) string { + switch strings.ToLower(strings.TrimSpace(channel)) { + case "", ChannelCSGClaw: + return ChannelCSGClaw + case ChannelFeishu: + return ChannelFeishu + default: + return "" + } +} + +func normalizeType(typ string) string { + switch strings.ToLower(strings.TrimSpace(typ)) { + case TypeHuman: + return TypeHuman + case TypeAgent: + return TypeAgent + case TypeNotification: + return TypeNotification + default: + return "" + } +} + +func normalizeBindingMode(mode string) string { + switch strings.ToLower(strings.TrimSpace(mode)) { + case BindingModeCreate: + return BindingModeCreate + case BindingModeReuse: + return BindingModeReuse + case "", BindingModeNone: + return BindingModeNone + default: + return strings.ToLower(strings.TrimSpace(mode)) + } +} + +func defaultAgentID(participantID string) string { + return "u-" + strings.TrimSpace(participantID) +} + +func slugify(raw string) string { + raw = strings.ToLower(strings.TrimSpace(raw)) + var b strings.Builder + lastDash := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if unicode.IsLetter(r) || unicode.IsDigit(r) { + b.WriteRune(unicode.ToLower(r)) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + out := strings.Trim(b.String(), "-") + if len(out) > 48 { + out = strings.Trim(out[:48], "-") + } + if len(out) < 2 { + return "" + } + return out +} + +func randomSuffix() string { + var raw [5]byte + if _, err := rand.Read(raw[:]); err != nil { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + return strings.ToLower(strings.TrimRight(base32.StdEncoding.EncodeToString(raw[:]), "="))[:6] +} + +func cloneMetadata(src map[string]any) map[string]any { + if len(src) == 0 { + return nil + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} diff --git a/internal/participant/service_test.go b/internal/participant/service_test.go new file mode 100644 index 00000000..3b7ff973 --- /dev/null +++ b/internal/participant/service_test.go @@ -0,0 +1,416 @@ +package participant + +import ( + "context" + "strings" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/config" + "csgclaw/internal/im" + agentruntime "csgclaw/internal/runtime" + "csgclaw/internal/sandbox" + "csgclaw/internal/sandbox/sandboxtest" +) + +func TestCreateAgentParticipantUsesStableParticipantIDForDefaultAgentID(t *testing.T) { + agentSvc := mustNewAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.Create(context.Background(), CreateRequest{ + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA Display Name", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeCreate, + Agent: &agent.CreateAgentSpec{ + Name: "QA Display Name", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if created.ID != "qa" { + t.Fatalf("participant ID = %q, want qa", created.ID) + } + if created.AgentID != "u-qa" { + t.Fatalf("agent ID = %q, want u-qa", created.AgentID) + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was not created") + } + if _, ok := agentSvc.Agent("u-qa-display-name"); ok { + t.Fatal("agent ID was derived from editable display name") + } + if user, ok := imSvc.User("u-qa"); !ok || !strings.EqualFold(user.Name, "QA Display Name") { + t.Fatalf("channel user = %+v, ok=%v; want u-qa display user", user, ok) + } +} + +func TestCreateAgentParticipantCanReuseExistingAgentWithDifferentParticipantID(t *testing.T) { + agentSvc := mustNewAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + + created, err := svc.Create(context.Background(), CreateRequest{ + ID: "test", + Channel: ChannelFeishu, + Type: TypeAgent, + Name: "QA Feishu", + ChannelAppRef: "cli_xxx", + ChannelUser: ChannelUserSpec{ + Ref: "ou_xxx", + Kind: ChannelUserKindOpenID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }) + if err != nil { + t.Fatalf("Create() error = %v", err) + } + + if created.ID != "test" || created.AgentID != "u-qa" { + t.Fatalf("created participant = %+v, want id test bound to u-qa", created) + } + if created.ChannelUserRef != "ou_xxx" || created.ChannelAppRef != "cli_xxx" { + t.Fatalf("created participant channel identity = %+v, want Feishu app/open_id scope", created) + } +} + +func TestEnsureBootstrapManagerUsesDefaultParticipantIDSeparateFromAgentID(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID { + t.Fatalf("participant ID = %q, want %q", created.ID, agent.ManagerParticipantID) + } + if created.AgentID != agent.ManagerUserID { + t.Fatalf("agent ID = %q, want %q", created.AgentID, agent.ManagerUserID) + } + if created.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, agent.ManagerParticipantID) + } + if user, ok := imSvc.User(agent.ManagerParticipantID); !ok || user.ID != agent.ManagerParticipantID { + t.Fatalf("manager channel user = %+v, ok=%v; want local user %q", user, ok, agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerParticipantID); !ok { + t.Fatalf("store missing manager participant %q", agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("store still has manager participant under agent ID %q", agent.ManagerUserID) + } +} + +func TestEnsureBootstrapManagerRenamesLegacyManagerParticipant(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + store := NewMemoryStore([]Participant{{ + ID: agent.ManagerUserID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "manager", + Avatar: "avatar.png", + ChannelUserRef: agent.ManagerUserID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }}) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID || created.AgentID != agent.ManagerUserID { + t.Fatalf("manager participant = %+v, want id %q bound to agent %q", created, agent.ManagerParticipantID, agent.ManagerUserID) + } + if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" { + t.Fatalf("manager participant did not preserve legacy fields: %+v", created) + } + if _, ok := store.Get(ChannelCSGClaw, agent.ManagerUserID); ok { + t.Fatalf("legacy manager participant %q was not deleted", agent.ManagerUserID) + } +} + +func TestEnsureBootstrapManagerDeletesMisspelledManagerParticipant(t *testing.T) { + agentSvc := mustNewManagerAgentService(t) + imSvc := im.NewService() + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + legacyID := "man" + "ger" + store := NewMemoryStore([]Participant{{ + ID: legacyID, + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "manager", + Avatar: "avatar.png", + ChannelUserRef: legacyID, + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }}) + svc := NewService(store, WithAgentService(agentSvc), WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapManager(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapManager() error = %v", err) + } + + if created.ID != agent.ManagerParticipantID || created.ChannelUserRef != agent.ManagerParticipantID || created.AgentID != agent.ManagerUserID { + t.Fatalf("manager participant = %+v, want manager participant bound to %q", created, agent.ManagerUserID) + } + if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" { + t.Fatalf("manager participant did not preserve legacy fields: %+v", created) + } + if _, ok := store.Get(ChannelCSGClaw, legacyID); ok { + t.Fatalf("legacy manager participant %q was not deleted", legacyID) + } +} + +func TestDeleteParticipantDoesNotDeleteAgentByDefault(t *testing.T) { + agentSvc := mustNewAgentService(t) + svc := NewService(NewMemoryStore(nil), WithAgentService(agentSvc), WithIMService(im.NewService())) + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + if _, err := svc.Create(context.Background(), CreateRequest{ + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + + if _, ok, err := svc.Delete(context.Background(), ChannelCSGClaw, "qa", DeleteOptions{}); err != nil || !ok { + t.Fatalf("Delete() ok=%v error=%v, want ok", ok, err) + } + if _, ok := svc.Get(ChannelCSGClaw, "qa"); ok { + t.Fatal("participant csgclaw:qa still exists after delete") + } + if _, ok := agentSvc.Agent("u-qa"); !ok { + t.Fatal("agent u-qa was deleted by default participant delete") + } +} + +func TestDeleteParticipantRejectsAgentCleanupWhenStillReferenced(t *testing.T) { + agentSvc := mustNewAgentService(t) + svc := NewService(NewMemoryStore(nil), WithAgentService(agentSvc), WithIMService(im.NewService())) + if _, err := agentSvc.Create(context.Background(), agent.CreateRequest{ + Spec: agent.CreateAgentSpec{ + ID: "u-qa", + Name: "QA Runtime", + Role: agent.RoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "agent-image:test", + }, + }); err != nil { + t.Fatalf("seed agent: %v", err) + } + for _, req := range []CreateRequest{ + { + ID: "qa", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "QA", + ChannelUser: ChannelUserSpec{ + Ref: "u-qa", + Kind: ChannelUserKindLocalUserID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }, + { + ID: "test", + Channel: ChannelFeishu, + Type: TypeAgent, + Name: "QA Feishu", + ChannelAppRef: "cli_xxx", + ChannelUser: ChannelUserSpec{ + Ref: "ou_xxx", + Kind: ChannelUserKindOpenID, + }, + AgentBinding: AgentBindingSpec{ + Mode: BindingModeReuse, + AgentID: "u-qa", + }, + }, + } { + if _, err := svc.Create(context.Background(), req); err != nil { + t.Fatalf("Create(%s:%s) error = %v", req.Channel, req.ID, err) + } + } + + _, ok, err := svc.Delete(context.Background(), ChannelCSGClaw, "qa", DeleteOptions{DeleteAgent: DeleteAgentIfUnreferenced}) + if err == nil { + t.Fatal("Delete(delete_agent=if_unreferenced) error = nil, want referenced-agent error") + } + if ok { + t.Fatal("Delete(delete_agent=if_unreferenced) ok = true, want false when cleanup is rejected") + } + if _, exists := svc.Get(ChannelCSGClaw, "qa"); !exists { + t.Fatal("participant csgclaw:qa was deleted despite referenced-agent rejection") + } + if _, exists := agentSvc.Agent("u-qa"); !exists { + t.Fatal("agent u-qa was deleted despite referenced-agent rejection") + } +} + +func TestCreateHumanParticipantRejectsCreateAgentBinding(t *testing.T) { + svc := NewService(NewMemoryStore(nil)) + + _, err := svc.Create(context.Background(), CreateRequest{ + ID: "alice", + Channel: ChannelCSGClaw, + Type: TypeHuman, + Name: "Alice", + AgentBinding: AgentBindingSpec{ + Mode: BindingModeCreate, + }, + }) + if err == nil { + t.Fatal("Create() error = nil, want validation error") + } +} + +func mustNewAgentService(t *testing.T) *agent.Service { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + + svc, err := agent.NewService( + config.ModelConfig{ + Provider: config.ProviderLLMAPI, + BaseURL: "http://127.0.0.1:4000", + APIKey: "sk-test", + ModelID: "model-1", + }, + config.ServerConfig{}, + "manager-image:test", + "", + agent.WithRuntime(testRuntime{kind: agent.RuntimeKindPicoClawSandbox}), + ) + if err != nil { + t.Fatalf("agent.NewService() error = %v", err) + } + return svc +} + +func mustNewManagerAgentService(t *testing.T) *agent.Service { + t.Helper() + svc := mustNewAgentService(t) + agent.SetTestHooks( + func(_ *agent.Service, _ string) (sandbox.Runtime, error) { + return sandboxtest.NewRuntime(), nil + }, + func(_ *agent.Service, _ context.Context, _ sandbox.Runtime, _, name, _ string, _ agent.AgentProfile) (sandbox.Instance, sandbox.Info, error) { + info := sandbox.Info{ + ID: "box-" + name, + Name: name, + State: sandbox.StateRunning, + CreatedAt: time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC), + } + return sandboxtest.NewInstance(info), info, nil + }, + ) + t.Cleanup(agent.ResetTestHooks) + return svc +} + +type testRuntime struct { + kind string +} + +func (r testRuntime) Kind() string { + return r.kind +} + +func (testRuntime) WorkspaceRoot(agentHome string) string { + return agentHome +} + +func (testRuntime) New(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: spec.AgentID}, nil +} + +func (testRuntime) Start(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateRunning, nil +} + +func (testRuntime) Stop(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateStopped, nil +} + +func (testRuntime) Delete(context.Context, agentruntime.Handle) error { + return nil +} + +func (testRuntime) State(context.Context, agentruntime.Handle) (agentruntime.State, error) { + return agentruntime.StateRunning, nil +} + +func (testRuntime) Info(context.Context, agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{State: agentruntime.StateRunning, CreatedAt: time.Now().UTC()}, nil +} diff --git a/internal/participant/store.go b/internal/participant/store.go new file mode 100644 index 00000000..58b8c940 --- /dev/null +++ b/internal/participant/store.go @@ -0,0 +1,419 @@ +package participant + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +type Store struct { + mu sync.RWMutex + path string + items map[string]apitypes.Participant +} + +type persistedState struct { + Participants []apitypes.Participant `json:"participants"` +} + +type legacyBotState struct { + Bots []apitypes.Bot `json:"bots"` +} + +func NewStore(path string) (*Store, error) { + s := &Store{ + path: path, + items: make(map[string]apitypes.Participant), + } + if err := s.load(); err != nil { + return nil, err + } + return s, nil +} + +func NewMemoryStore(items []apitypes.Participant) *Store { + s := &Store{items: make(map[string]apitypes.Participant)} + for _, item := range items { + item = normalizeStoredParticipant(item) + if item.Channel == "" || item.ID == "" { + continue + } + s.items[storeKey(item.Channel, item.ID)] = item + } + return s +} + +func (s *Store) List(opts ListOptions) []apitypes.Participant { + if s == nil { + return nil + } + channel := strings.TrimSpace(opts.Channel) + typ := strings.TrimSpace(opts.Type) + agentID := strings.TrimSpace(opts.AgentID) + + s.mu.RLock() + defer s.mu.RUnlock() + + out := make([]apitypes.Participant, 0, len(s.items)) + for _, item := range s.items { + if channel != "" && item.Channel != channel { + continue + } + if typ != "" && item.Type != typ { + continue + } + if agentID != "" && item.AgentID != agentID { + continue + } + out = append(out, cloneParticipant(item)) + } + sortParticipants(out) + return out +} + +func (s *Store) Get(channel, id string) (apitypes.Participant, bool) { + if s == nil { + return apitypes.Participant{}, false + } + s.mu.RLock() + defer s.mu.RUnlock() + item, ok := s.items[storeKey(channel, id)] + if !ok { + return apitypes.Participant{}, false + } + return cloneParticipant(item), true +} + +func (s *Store) Save(item apitypes.Participant) error { + if s == nil { + return fmt.Errorf("participant store is required") + } + item = normalizeStoredParticipant(item) + if item.Channel == "" { + return fmt.Errorf("channel is required") + } + if item.ID == "" { + return fmt.Errorf("id is required") + } + + s.mu.Lock() + defer s.mu.Unlock() + s.items[storeKey(item.Channel, item.ID)] = item + return s.saveLocked() +} + +func (s *Store) Delete(channel, id string) (apitypes.Participant, bool, error) { + if s == nil { + return apitypes.Participant{}, false, fmt.Errorf("participant store is required") + } + channel = strings.TrimSpace(channel) + id = strings.TrimSpace(id) + if channel == "" || id == "" { + return apitypes.Participant{}, false, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + key := storeKey(channel, id) + item, ok := s.items[key] + if !ok { + return apitypes.Participant{}, false, nil + } + delete(s.items, key) + if err := s.saveLocked(); err != nil { + s.items[key] = item + return apitypes.Participant{}, false, err + } + return cloneParticipant(item), true, nil +} + +func (s *Store) load() error { + items, err := s.readState() + if err != nil { + return err + } + legacyPath, legacyExists, err := mergeLegacyBotState(s.path, items) + if err != nil { + return err + } + s.items = items + if legacyExists { + if err := s.saveLocked(); err != nil { + return fmt.Errorf("write migrated participant state: %w", err) + } + if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete legacy bot state after participant migration: %w", err) + } + } + return nil +} + +func (s *Store) readState() (map[string]apitypes.Participant, error) { + items := make(map[string]apitypes.Participant) + if s.path == "" { + return items, nil + } + data, err := os.ReadFile(s.path) + if err != nil { + if os.IsNotExist(err) { + return items, nil + } + return nil, fmt.Errorf("read participant state: %w", err) + } + var state persistedState + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("decode participant state: %w", err) + } + for _, item := range state.Participants { + item = normalizeStoredParticipant(item) + if item.Channel == "" || item.ID == "" { + return nil, fmt.Errorf("decode participant state: channel and id are required") + } + items[storeKey(item.Channel, item.ID)] = item + } + return items, nil +} + +func (s *Store) saveLocked() error { + if s.path == "" { + return nil + } + items := make([]apitypes.Participant, 0, len(s.items)) + for _, item := range s.items { + items = append(items, cloneParticipant(item)) + } + sortParticipants(items) + data, err := json.MarshalIndent(persistedState{Participants: items}, "", " ") + if err != nil { + return fmt.Errorf("encode participant state: %w", err) + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { + return fmt.Errorf("create participant state dir: %w", err) + } + if err := os.WriteFile(s.path, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("write participant state: %w", err) + } + return nil +} + +func normalizeStoredParticipant(item apitypes.Participant) apitypes.Participant { + item.ID = strings.TrimSpace(item.ID) + item.Channel = strings.TrimSpace(item.Channel) + item.Type = strings.TrimSpace(item.Type) + item.Name = strings.TrimSpace(item.Name) + item.ChannelUserRef = strings.TrimSpace(item.ChannelUserRef) + item.ChannelUserKind = strings.TrimSpace(item.ChannelUserKind) + item.ChannelAppRef = strings.TrimSpace(item.ChannelAppRef) + item.AgentID = strings.TrimSpace(item.AgentID) + item.LifecycleStatus = strings.TrimSpace(item.LifecycleStatus) + item.Presence = strings.TrimSpace(item.Presence) + return item +} + +func sortParticipants(items []apitypes.Participant) { + slices.SortFunc(items, func(a, b apitypes.Participant) int { + if a.CreatedAt.Equal(b.CreatedAt) { + if a.Channel != b.Channel { + if a.Channel < b.Channel { + return -1 + } + return 1 + } + if a.ID < b.ID { + return -1 + } + if a.ID > b.ID { + return 1 + } + return 0 + } + if a.CreatedAt.Before(b.CreatedAt) { + return -1 + } + return 1 + }) +} + +func cloneParticipant(item apitypes.Participant) apitypes.Participant { + if item.Metadata != nil { + cloned := make(map[string]any, len(item.Metadata)) + for key, value := range item.Metadata { + cloned[key] = value + } + item.Metadata = cloned + } + return item +} + +func storeKey(channel, id string) string { + return strings.TrimSpace(channel) + "\x00" + strings.TrimSpace(id) +} + +func mergeLegacyBotState(participantPath string, items map[string]apitypes.Participant) (string, bool, error) { + if strings.TrimSpace(participantPath) == "" { + return "", false, nil + } + legacyPath := filepath.Join(filepath.Dir(participantPath), "bots.json") + data, err := os.ReadFile(legacyPath) + if err != nil { + if os.IsNotExist(err) { + return "", false, nil + } + return legacyPath, true, fmt.Errorf("read legacy bot state: %w", err) + } + + var state legacyBotState + if err := json.Unmarshal(data, &state); err != nil { + return legacyPath, true, fmt.Errorf("decode legacy bot state: %w", err) + } + now := time.Now().UTC() + for _, b := range state.Bots { + item, err := participantFromLegacyBot(b, now) + if err != nil { + return legacyPath, true, fmt.Errorf("decode legacy bot state: %w", err) + } + key := storeKey(item.Channel, item.ID) + if _, exists := items[key]; exists { + continue + } + items[key] = item + } + return legacyPath, true, nil +} + +func participantFromLegacyBot(b apitypes.Bot, now time.Time) (apitypes.Participant, error) { + legacyID := strings.TrimSpace(b.ID) + if legacyID == "" { + return apitypes.Participant{}, fmt.Errorf("id is required") + } + channel := normalizeChannel(b.Channel) + if channel == "" { + return apitypes.Participant{}, fmt.Errorf("channel must be one of %q or %q", ChannelCSGClaw, ChannelFeishu) + } + typ := TypeAgent + if strings.EqualFold(strings.TrimSpace(b.Type), TypeNotification) { + typ = TypeNotification + } + name := strings.TrimSpace(b.Name) + if name == "" { + name = legacyID + } + channelUserRef := strings.TrimSpace(b.UserID) + if channelUserRef == "" { + channelUserRef = strings.TrimSpace(b.AgentID) + } + if channelUserRef == "" { + channelUserRef = legacyID + } + channelUserKind := ChannelUserKindLocalUserID + if channel == ChannelFeishu { + channelUserKind = ChannelUserKindOpenID + } + agentID := strings.TrimSpace(b.AgentID) + if typ == TypeAgent && agentID == "" && strings.HasPrefix(legacyID, "u-") { + agentID = legacyID + } + if typ == TypeNotification { + agentID = "" + } + id := legacyID + if isLegacyCSGClawManagerBot(b, typ, channel, agentID) { + id = agent.ManagerParticipantID + channelUserRef = agent.ManagerParticipantID + if agentID == "" { + agentID = agent.ManagerUserID + } + } + createdAt := b.CreatedAt.UTC() + if createdAt.IsZero() { + createdAt = now + } + + return apitypes.Participant{ + ID: id, + Channel: channel, + Type: typ, + Name: name, + Avatar: strings.TrimSpace(b.Avatar), + ChannelUserRef: channelUserRef, + ChannelUserKind: channelUserKind, + AgentID: agentID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: legacyBotMetadata(b), + CreatedAt: createdAt, + UpdatedAt: createdAt, + }, nil +} + +func isLegacyCSGClawManagerBot(b apitypes.Bot, typ, channel, agentID string) bool { + if channel != ChannelCSGClaw || typ != TypeAgent { + return false + } + if strings.TrimSpace(agentID) == agent.ManagerUserID { + return true + } + if strings.TrimSpace(b.ID) == agent.ManagerUserID { + return true + } + return strings.EqualFold(strings.TrimSpace(b.Role), agent.RoleManager) +} + +func legacyBotMetadata(b apitypes.Bot) map[string]any { + metadata := cloneAnyMap(b.RuntimeOptions) + putMetadataString(metadata, "description", b.Description) + putMetadataString(metadata, "legacy_bot_type", b.Type) + putMetadataString(metadata, "legacy_role", b.Role) + putMetadataString(metadata, "legacy_runtime_kind", b.RuntimeKind) + putMetadataString(metadata, "legacy_image", b.Image) + putMetadataString(metadata, "legacy_status", b.Status) + putMetadataString(metadata, "legacy_provider", b.Provider) + putMetadataString(metadata, "legacy_model_id", b.ModelID) + if strings.TrimSpace(b.AgentID) != "" && strings.EqualFold(strings.TrimSpace(b.Type), TypeNotification) { + putMetadataString(metadata, "legacy_agent_id", b.AgentID) + } + metadata["legacy_available"] = b.Available + if b.ProfileComplete { + metadata["legacy_profile_complete"] = true + } + if b.EnvRestartRequired { + metadata["legacy_env_restart_required"] = true + } + if b.ImageUpgradeRequired { + metadata["legacy_image_upgrade_required"] = true + } + if len(metadata) == 0 { + return nil + } + return metadata +} + +func cloneAnyMap(src map[string]any) map[string]any { + if len(src) == 0 { + return map[string]any{} + } + dst := make(map[string]any, len(src)) + for key, value := range src { + dst[key] = value + } + return dst +} + +func putMetadataString(metadata map[string]any, key, value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, exists := metadata[key]; exists { + return + } + metadata[key] = value +} diff --git a/internal/participant/store_test.go b/internal/participant/store_test.go new file mode 100644 index 00000000..92ed5320 --- /dev/null +++ b/internal/participant/store_test.go @@ -0,0 +1,112 @@ +package participant + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "csgclaw/internal/agent" + "csgclaw/internal/apitypes" +) + +func TestNewStoreMigratesLegacyBotsAndDeletesSource(t *testing.T) { + dir := t.TempDir() + participantsPath := filepath.Join(dir, "participants.json") + botsPath := filepath.Join(dir, "bots.json") + + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + writeJSONFile(t, participantsPath, persistedState{Participants: []apitypes.Participant{ + { + ID: "dev", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "dev", + ChannelUserRef: "u-dev", + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: "u-dev", + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + CreatedAt: createdAt.Add(time.Minute), + UpdatedAt: createdAt.Add(time.Minute), + }, + }}) + writeJSONFile(t, botsPath, legacyBotState{Bots: []apitypes.Bot{ + { + ID: "u-manager", + Name: "manager", + Type: "normal", + Role: "manager", + Channel: ChannelCSGClaw, + AgentID: "u-manager", + UserID: "u-manager", + Available: true, + CreatedAt: createdAt, + }, + { + ID: "n-alerts", + Name: "alerts", + Type: TypeNotification, + Role: "worker", + Channel: ChannelCSGClaw, + UserID: "n-alerts", + RuntimeOptions: map[string]any{ + "delivery_mode": "webhook", + "webhook_token": "secret-token", + }, + CreatedAt: createdAt.Add(2 * time.Minute), + }, + }}) + + store, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + manager, ok := store.Get(ChannelCSGClaw, agent.ManagerParticipantID) + if !ok { + t.Fatal("manager participant was not migrated from legacy bots.json") + } + if manager.Type != TypeAgent || manager.AgentID != agent.ManagerUserID || manager.ChannelUserRef != agent.ManagerParticipantID { + t.Fatalf("manager participant = %+v, want agent %q and channel user %q", manager, agent.ManagerUserID, agent.ManagerParticipantID) + } + if _, ok := store.Get(ChannelCSGClaw, "u-manager"); ok { + t.Fatal("manager participant was migrated under agent ID u-manager") + } + if manager.ChannelUserKind != ChannelUserKindLocalUserID || !manager.Mentionable || manager.LifecycleStatus != LifecycleStatusActive { + t.Fatalf("manager identity fields = %+v, want active mentionable local user", manager) + } + + notify, ok := store.Get(ChannelCSGClaw, "n-alerts") + if !ok { + t.Fatal("notification participant was not migrated from legacy bots.json") + } + if notify.Type != TypeNotification || notify.ChannelUserRef != "n-alerts" { + t.Fatalf("notification participant = %+v, want dedicated notification identity", notify) + } + if notify.Metadata["delivery_mode"] != "webhook" || notify.Metadata["webhook_token"] != "secret-token" { + t.Fatalf("notification metadata = %#v, want legacy runtime_options preserved", notify.Metadata) + } + + if _, err := os.Stat(botsPath); !os.IsNotExist(err) { + t.Fatalf("bots.json still exists after successful migration; stat err=%v", err) + } + if _, ok := store.Get(ChannelCSGClaw, "dev"); !ok { + t.Fatal("existing participant was not preserved during migration") + } +} + +func writeJSONFile(t *testing.T, path string, value any) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + data, err := json.MarshalIndent(value, "", " ") + if err != nil { + t.Fatalf("MarshalIndent() error = %v", err) + } + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } +} diff --git a/internal/runtime/openclawsandbox/config.go b/internal/runtime/openclawsandbox/config.go index 06b5749b..2ea8c581 100644 --- a/internal/runtime/openclawsandbox/config.go +++ b/internal/runtime/openclawsandbox/config.go @@ -43,12 +43,12 @@ func HostGatewayLogPath(agentHome string) string { return filepath.Join(Root(agentHome), "gateway.log") } -func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) (string, error) { +func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create openclaw config dir: %w", err) } - data, err := renderConfig(botID, server, model, resolveBaseURL, feishuProvider) + data, err := renderConfig(participantID, agentID, server, model, resolveBaseURL, feishuProvider) if err != nil { return "", err } @@ -114,18 +114,26 @@ func writeExecApprovalsAllowAll(hostRoot string) error { return nil } -func renderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) ([]byte, error) { +func renderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver, feishuProvider feishu.BotCredentialProvider) ([]byte, error) { + participantID = strings.TrimSpace(participantID) + agentID = strings.TrimSpace(agentID) + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } var cfg map[string]any if err := json.Unmarshal(defaultOpenClawGatewayConfig, &cfg); err != nil { return nil, fmt.Errorf("decode embedded openclaw config: %w", err) } - if err := updateOpenClawModelProvider(cfg, botID, server, model, resolveBaseURL); err != nil { + if err := updateOpenClawModelProvider(cfg, agentID, server, model, resolveBaseURL); err != nil { return nil, err } - if err := updateOpenClawCsgclawChannel(cfg, botID, server, resolveBaseURL); err != nil { + if err := updateOpenClawCsgclawChannel(cfg, participantID, server, resolveBaseURL); err != nil { return nil, err } - if err := updateOpenClawFeishuChannel(cfg, botID, feishuProvider); err != nil { + if err := updateOpenClawFeishuChannel(cfg, agentID, feishuProvider); err != nil { return nil, err } if err := updateOpenClawGatewayAuth(cfg, server); err != nil { @@ -202,7 +210,7 @@ func updateOpenClawPrimaryModel(cfg map[string]any, providerID, modelID string) return nil } -func updateOpenClawCsgclawChannel(cfg map[string]any, botID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { +func updateOpenClawCsgclawChannel(cfg map[string]any, participantID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { channels, ok := cfg["channels"].(map[string]any) if !ok { return fmt.Errorf("embedded openclaw config is missing channels") @@ -217,7 +225,7 @@ func updateOpenClawCsgclawChannel(cfg map[string]any, botID string, server confi if server.AccessToken != "" { ch["accessToken"] = server.AccessToken } - ch["botId"] = botID + ch["botId"] = participantID ch["enabled"] = true return nil } @@ -290,9 +298,9 @@ func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) return strings.TrimRight(strings.TrimSpace(resolveBaseURL(server)), "/") } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } func updateOpenClawGatewayAuth(cfg map[string]any, server config.ServerConfig) error { diff --git a/internal/runtime/openclawsandbox/config_test.go b/internal/runtime/openclawsandbox/config_test.go index b87f6927..83384ab1 100644 --- a/internal/runtime/openclawsandbox/config_test.go +++ b/internal/runtime/openclawsandbox/config_test.go @@ -10,7 +10,7 @@ import ( ) func TestRenderAgentOpenClawConfigUsesOpenAICompatForMinimaxBaseURL(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "gateway-shared-token", @@ -56,7 +56,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForMinimaxBaseURL(t *testing.T } func TestRenderAgentOpenClawConfigUsesOpenAICompatForInfiniMaaS(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "gateway-shared-token", @@ -105,7 +105,7 @@ func TestRenderAgentOpenClawConfigUsesOpenAICompatForInfiniMaaS(t *testing.T) { } func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -116,7 +116,7 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { t.Fatalf("renderAgentOpenClawConfig() error = %v", err) } text := string(data) - if !strings.Contains(text, `http://127.0.0.1:18080/api/bots/u-manager/llm`) { + if !strings.Contains(text, `http://127.0.0.1:18080/api/v1/agents/u-manager/llm`) { t.Fatalf("expected CSGClaw LLM bridge URL in config:\n%s", text) } for _, placeholder := range []string{ @@ -132,8 +132,33 @@ func TestRenderAgentOpenClawConfigUsesBridgeWhenBaseURLEmpty(t *testing.T) { } } +func TestRenderAgentOpenClawConfigSplitsParticipantAndAgentID(t *testing.T) { + data, err := renderConfig("manager", "u-manager", config.ServerConfig{ + ListenAddr: "127.0.0.1:18080", + AdvertiseBaseURL: "http://127.0.0.1:18080", + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "MiniMax-M2.7", + }, testBaseURLResolver, nil) + if err != nil { + t.Fatalf("renderAgentOpenClawConfig() error = %v", err) + } + text := string(data) + for _, want := range []string{ + `"botId": "manager"`, + `"baseUrl": "http://127.0.0.1:18080/api/v1/agents/u-manager/llm"`, + } { + if !strings.Contains(text, want) { + t.Fatalf("rendered OpenClaw config missing %q:\n%s", want, text) + } + } + if strings.Contains(text, `/api/v1/agents/manager/llm`) { + t.Fatalf("rendered OpenClaw config used participant ID for LLM bridge:\n%s", text) + } +} + func TestRenderAgentOpenClawConfigDisablesStartupUpdateCheck(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -154,7 +179,7 @@ func TestRenderAgentOpenClawConfigDisablesStartupUpdateCheck(t *testing.T) { } func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -185,7 +210,7 @@ func TestRenderAgentOpenClawConfigDefaultsCsgclawGroupsToMentionOnly(t *testing. } func TestRenderAgentOpenClawConfigAddsFeishuChannelWhenConfigured(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "127.0.0.1:18080", AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "shared-token", @@ -237,7 +262,7 @@ func TestRenderAgentOpenClawConfigAddsFeishuChannelWhenConfigured(t *testing.T) } func TestRenderAgentOpenClawConfigPassesThroughDockerHostAlias(t *testing.T) { - data, err := renderConfig("u-manager", config.ServerConfig{ + data, err := renderConfig("u-manager", "u-manager", config.ServerConfig{ ListenAddr: "0.0.0.0:18080", AdvertiseBaseURL: "http://host.docker.internal:18080", AccessToken: "shared-token", diff --git a/internal/runtime/openclawsandbox/runtime.go b/internal/runtime/openclawsandbox/runtime.go index 5c564dbf..f1b96e9d 100644 --- a/internal/runtime/openclawsandbox/runtime.go +++ b/internal/runtime/openclawsandbox/runtime.go @@ -67,7 +67,11 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if agentHome == "" { return fmt.Errorf("gateway agent home is required") } - if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = strings.TrimSpace(req.AgentID) + } + if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL), r.CurrentFeishuProvider()); err != nil { return err } workspaceRoot := r.WorkspaceRoot(agentHome) diff --git a/internal/runtime/picoclawsandbox/config.go b/internal/runtime/picoclawsandbox/config.go index 7a106eb2..4b32d636 100644 --- a/internal/runtime/picoclawsandbox/config.go +++ b/internal/runtime/picoclawsandbox/config.go @@ -47,13 +47,13 @@ func WorkspaceConfigRoot(agentHome string) string { return Root(agentHome) } -func EnsureConfig(agentHome, botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) (string, error) { +func EnsureConfig(agentHome, participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) (string, error) { hostRoot := Root(agentHome) if err := os.MkdirAll(hostRoot, 0o755); err != nil { return "", fmt.Errorf("create picoclaw config dir: %w", err) } - data, err := RenderConfig(botID, server, model, resolveBaseURL) + data, err := RenderConfig(participantID, agentID, server, model, resolveBaseURL) if err != nil { return "", err } @@ -69,16 +69,24 @@ func EnsureConfig(agentHome, botID string, server config.ServerConfig, model con return hostRoot, nil } -func RenderConfig(botID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) ([]byte, error) { +func RenderConfig(participantID, agentID string, server config.ServerConfig, model config.ModelConfig, resolveBaseURL BaseURLResolver) ([]byte, error) { + participantID = strings.TrimSpace(participantID) + agentID = strings.TrimSpace(agentID) + if participantID == "" { + participantID = agentID + } + if agentID == "" { + agentID = participantID + } var cfg map[string]any if err := json.Unmarshal(defaultGatewayConfig, &cfg); err != nil { return nil, fmt.Errorf("decode embedded manager picoclaw config: %w", err) } - if err := updateModelList(cfg, botID, server, model, resolveBaseURL); err != nil { + if err := updateModelList(cfg, agentID, server, model, resolveBaseURL); err != nil { return nil, err } - if err := updateCSGClawChannel(cfg, botID, server, resolveBaseURL); err != nil { + if err := updateCSGClawChannel(cfg, participantID, server, resolveBaseURL); err != nil { return nil, err } @@ -89,7 +97,7 @@ func RenderConfig(botID string, server config.ServerConfig, model config.ModelCo return data, nil } -func updateModelList(cfg map[string]any, botID string, server config.ServerConfig, modelCfg config.ModelConfig, resolveBaseURL BaseURLResolver) error { +func updateModelList(cfg map[string]any, agentID string, server config.ServerConfig, modelCfg config.ModelConfig, resolveBaseURL BaseURLResolver) error { modelList, ok := cfg["model_list"].([]any) if !ok || len(modelList) == 0 { return fmt.Errorf("embedded manager picoclaw config is missing model_list[0]") @@ -111,7 +119,7 @@ func updateModelList(cfg map[string]any, botID string, server config.ServerConfi } if managerBaseURL := managerBaseURL(server, resolveBaseURL); managerBaseURL != "" { - model["api_base"] = llmBridgeBaseURL(managerBaseURL, botID) + model["api_base"] = llmBridgeBaseURL(managerBaseURL, agentID) } if server.AccessToken != "" { model["api_key"] = server.AccessToken @@ -133,7 +141,7 @@ func BridgeModelID(modelID string) string { return "openai/" + modelID } -func updateCSGClawChannel(cfg map[string]any, botID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { +func updateCSGClawChannel(cfg map[string]any, participantID string, server config.ServerConfig, resolveBaseURL BaseURLResolver) error { channels, ok := cfg["channels"].(map[string]any) if !ok { return fmt.Errorf("embedded manager picoclaw config is missing channels") @@ -148,7 +156,8 @@ func updateCSGClawChannel(cfg map[string]any, botID string, server config.Server if server.AccessToken != "" { channel["access_token"] = server.AccessToken } - channel["bot_id"] = botID + delete(channel, "bot_id") + channel["participant_id"] = participantID channel["enabled"] = true return nil } @@ -175,7 +184,7 @@ func managerBaseURL(server config.ServerConfig, resolveBaseURL BaseURLResolver) return strings.TrimRight(strings.TrimSpace(resolveBaseURL(server)), "/") } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } diff --git a/internal/runtime/picoclawsandbox/config_test.go b/internal/runtime/picoclawsandbox/config_test.go new file mode 100644 index 00000000..094ec9c1 --- /dev/null +++ b/internal/runtime/picoclawsandbox/config_test.go @@ -0,0 +1,39 @@ +package picoclawsandbox + +import ( + "encoding/json" + "testing" + + "csgclaw/internal/config" +) + +func TestRenderConfigDisablesUnconfiguredFeishuChannel(t *testing.T) { + data, err := RenderConfig("u-manager", "u-manager", config.ServerConfig{ + AccessToken: "shared-token", + }, config.ModelConfig{ + ModelID: "gpt-5.5", + }, fixedBaseURL("http://127.0.0.1:18080")) + if err != nil { + t.Fatalf("RenderConfig() error = %v", err) + } + + var rendered struct { + Channels map[string]map[string]any `json:"channels"` + } + if err := json.Unmarshal(data, &rendered); err != nil { + t.Fatalf("RenderConfig() produced invalid JSON: %v", err) + } + feishu, ok := rendered.Channels["feishu"] + if !ok { + t.Fatalf("RenderConfig() missing channels.feishu in:\n%s", data) + } + if got, want := feishu["enabled"], false; got != want { + t.Fatalf("channels.feishu.enabled = %v, want %v in:\n%s", got, want, data) + } + if got, want := feishu["app_id"], ""; got != want { + t.Fatalf("channels.feishu.app_id = %q, want empty in:\n%s", got, data) + } + if got, want := feishu["app_secret"], ""; got != want { + t.Fatalf("channels.feishu.app_secret = %q, want empty in:\n%s", got, data) + } +} diff --git a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json index f67cad86..ec35c593 100644 --- a/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json +++ b/internal/runtime/picoclawsandbox/defaults/picoclaw-config.json @@ -36,7 +36,7 @@ "csgclaw": { "enabled": true, "base_url": "http://127.0.0.1:18080", - "bot_id": "u-manager", + "participant_id": "u-manager", "access_token": "your_access_token", "allow_from": [], "group_trigger": { @@ -45,9 +45,9 @@ "reasoning_channel_id": "" }, "feishu": { - "enabled": true, - "app_id": "bot_app_id", - "app_secret": "bot_app_secret", + "enabled": false, + "app_id": "", + "app_secret": "", "encrypt_key": "", "group_trigger": { "mention_only": true diff --git a/internal/runtime/picoclawsandbox/provision_test.go b/internal/runtime/picoclawsandbox/provision_test.go index 0857e4e2..6bc0144a 100644 --- a/internal/runtime/picoclawsandbox/provision_test.go +++ b/internal/runtime/picoclawsandbox/provision_test.go @@ -66,7 +66,7 @@ func TestGatewayCreateSpecMountsPicoClawRuntimeRoot(t *testing.T) { agentHome := t.TempDir() projectsRoot := t.TempDir() rt := New(Dependencies{ - BuildRuntimeEnv: func(_, _, _, _, _ string, _ feishu.BotCredentialProvider) map[string]string { + BuildRuntimeEnv: func(_, _, _, _, _, _ string, _ feishu.BotCredentialProvider) map[string]string { return map[string]string{} }, AddProfileEnv: func(map[string]string, map[string]string) {}, diff --git a/internal/runtime/picoclawsandbox/runtime.go b/internal/runtime/picoclawsandbox/runtime.go index 0dc712d3..025d7d54 100644 --- a/internal/runtime/picoclawsandbox/runtime.go +++ b/internal/runtime/picoclawsandbox/runtime.go @@ -71,7 +71,11 @@ func (r *Runtime) Provision(_ context.Context, req agentruntime.ProvisionRequest if agentHome == "" { return fmt.Errorf("gateway agent home is required") } - if _, err := EnsureConfig(agentHome, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL)); err != nil { + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = strings.TrimSpace(req.AgentID) + } + if _, err := EnsureConfig(agentHome, participantID, req.AgentID, gateway.Server, configModelFromProfile(profile), fixedBaseURL(gateway.ManagerBaseURL)); err != nil { return err } workspaceRoot := r.WorkspaceRoot(agentHome) diff --git a/internal/runtime/picoclawsandbox/runtime_channels_test.go b/internal/runtime/picoclawsandbox/runtime_channels_test.go index 24c58c93..2fbfdab2 100644 --- a/internal/runtime/picoclawsandbox/runtime_channels_test.go +++ b/internal/runtime/picoclawsandbox/runtime_channels_test.go @@ -17,9 +17,9 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { "u-dev": {AppID: "old-app", AppSecret: "old-secret"}, }, }, - BuildRuntimeEnv: func(_, _, botID, _, _ string, provider feishu.BotCredentialProvider) map[string]string { - env := map[string]string{} - if app, ok := provider.BotConfig(botID); ok { + BuildRuntimeEnv: func(_, _, participantID, agentID, _, _ string, provider feishu.BotCredentialProvider) map[string]string { + env := map[string]string{"PARTICIPANT_ID": participantID} + if app, ok := provider.BotConfig(agentID); ok { env["APP_ID"] = app.AppID env["APP_SECRET"] = app.AppSecret } @@ -28,9 +28,10 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { AddProfileEnv: func(envVars map[string]string, profileEnv map[string]string) {}, }) if err := rt.Provision(context.Background(), agentruntime.ProvisionRequest{ - RuntimeID: "rt-u-dev", - AgentID: "u-dev", - AgentName: "dev", + RuntimeID: "rt-u-dev", + AgentID: "u-dev", + ParticipantID: "dev", + AgentName: "dev", Gateway: &agentruntime.GatewayProvision{ ModelFallback: "model-1", Server: config.ServerConfig{AdvertiseBaseURL: "http://127.0.0.1:18080", AccessToken: "token"}, @@ -59,6 +60,9 @@ func TestRuntimeSetFeishuProviderUpdatesGatewayCreateSpecEnv(t *testing.T) { if got, want := spec.Env["APP_SECRET"], "new-secret"; got != want { t.Fatalf("APP_SECRET = %q, want %q", got, want) } + if got, want := spec.Env["PARTICIPANT_ID"], "dev"; got != want { + t.Fatalf("PARTICIPANT_ID = %q, want %q", got, want) + } } type feishuProviderStub struct { diff --git a/internal/runtime/provision.go b/internal/runtime/provision.go index 24145319..a14f60d2 100644 --- a/internal/runtime/provision.go +++ b/internal/runtime/provision.go @@ -26,6 +26,7 @@ type Provisioner interface { type ProvisionRequest struct { RuntimeID string AgentID string + ParticipantID string AgentName string Profile Profile WorkspaceOverlay string diff --git a/internal/runtime/sandboxgateway/runtime.go b/internal/runtime/sandboxgateway/runtime.go index dce471c7..d513ba48 100644 --- a/internal/runtime/sandboxgateway/runtime.go +++ b/internal/runtime/sandboxgateway/runtime.go @@ -52,7 +52,7 @@ type Dependencies struct { ResolveAgent func(h agentruntime.Handle) (AgentRef, error) SyncHandle func(h agentruntime.Handle) error - BuildRuntimeEnv func(baseURL, accessToken, botID, llmBaseURL, modelID string, feishuProvider feishu.BotCredentialProvider) map[string]string + BuildRuntimeEnv func(baseURL, accessToken, participantID, agentID, llmBaseURL, modelID string, feishuProvider feishu.BotCredentialProvider) map[string]string AddProfileEnv func(envVars map[string]string, profileEnv map[string]string) HomeEnv string MountGuestPath string @@ -339,13 +339,21 @@ func (r *Runtime) GatewayCreateSpec(image, name, botID string, profile agentrunt if err != nil { return sandbox.CreateSpec{}, err } + agentID := strings.TrimSpace(prepared.AgentID) + if agentID == "" { + agentID = strings.TrimSpace(botID) + } + participantID := strings.TrimSpace(prepared.ParticipantID) + if participantID == "" { + participantID = agentID + } modelID := prepared.ModelID managerBaseURL := strings.TrimRight(strings.TrimSpace(prepared.ManagerBaseURL), "/") - llmBaseURL := llmBridgeBaseURL(managerBaseURL, botID) + llmBaseURL := llmBridgeBaseURL(managerBaseURL, agentID) profile = prepared.Profile workspaceLayout := prepared.WorkspaceLayout projectsRoot := prepared.ProjectsRoot - envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, botID, llmBaseURL, modelID, r.CurrentFeishuProvider()) + envVars := r.deps.BuildRuntimeEnv(managerBaseURL, prepared.Server.AccessToken, participantID, agentID, llmBaseURL, modelID, r.CurrentFeishuProvider()) r.deps.AddProfileEnv(envVars, profile.Env) homeEnv := r.homeEnv() projectsGuestPath := r.projectsGuestPath() @@ -391,6 +399,8 @@ func (r *Runtime) GatewayCreateSpec(image, name, botID string, profile agentrunt } type PreparedGatewayProvision struct { + AgentID string + ParticipantID string ModelID string Profile agentruntime.Profile WorkspaceLayout WorkspaceLayout @@ -401,9 +411,14 @@ type PreparedGatewayProvision struct { func FinalizePreparedGatewayProvision(req agentruntime.ProvisionRequest, workspaceLayout WorkspaceLayout) (PreparedGatewayProvision, error) { name := strings.TrimSpace(req.AgentName) - if name == "" || strings.TrimSpace(req.AgentID) == "" { + agentID := strings.TrimSpace(req.AgentID) + if name == "" || agentID == "" { return PreparedGatewayProvision{}, fmt.Errorf("runtime agent name and id are required") } + participantID := strings.TrimSpace(req.ParticipantID) + if participantID == "" { + participantID = agentID + } gateway := req.Gateway if gateway == nil { return PreparedGatewayProvision{}, fmt.Errorf("gateway provisioning data is required") @@ -421,6 +436,8 @@ func FinalizePreparedGatewayProvision(req agentruntime.ProvisionRequest, workspa } } return PreparedGatewayProvision{ + AgentID: agentID, + ParticipantID: participantID, ModelID: modelID, Profile: profile, WorkspaceLayout: workspaceLayout, @@ -677,7 +694,7 @@ func stateFromSandboxState(state sandbox.State) agentruntime.State { } } -func llmBridgeBaseURL(managerBaseURL, botID string) string { +func llmBridgeBaseURL(managerBaseURL, agentID string) string { managerBaseURL = strings.TrimRight(strings.TrimSpace(managerBaseURL), "/") - return managerBaseURL + "/api/bots/" + strings.TrimSpace(botID) + "/llm" + return managerBaseURL + "/api/v1/agents/" + strings.TrimSpace(agentID) + "/llm" } diff --git a/internal/runtime/sandboxgateway/runtime_test.go b/internal/runtime/sandboxgateway/runtime_test.go index f866c1ed..e4052a4c 100644 --- a/internal/runtime/sandboxgateway/runtime_test.go +++ b/internal/runtime/sandboxgateway/runtime_test.go @@ -177,7 +177,7 @@ func testGatewayDeps(providerName func() string, run func(context.Context, sandb SyncHandle: func(agentruntime.Handle) error { return nil }, - BuildRuntimeEnv: func(string, string, string, string, string, feishu.BotCredentialProvider) map[string]string { + BuildRuntimeEnv: func(string, string, string, string, string, string, feishu.BotCredentialProvider) map[string]string { return map[string]string{} }, AddProfileEnv: func(map[string]string, map[string]string) {}, diff --git a/internal/server/accesslog_test.go b/internal/server/accesslog_test.go index e0eea99f..1f261615 100644 --- a/internal/server/accesslog_test.go +++ b/internal/server/accesslog_test.go @@ -19,7 +19,7 @@ func TestAccessLogCapturesImplicitOK(t *testing.T) { _, _ = w.Write([]byte("ok")) })) - req := httptest.NewRequest(http.MethodGet, "/api/v1/users?ready=1", nil) + req := httptest.NewRequest(http.MethodGet, "/api/v1/channels/csgclaw/users?ready=1", nil) req.RemoteAddr = "127.0.0.1:1234" req.Header.Set("User-Agent", "test-agent") rec := httptest.NewRecorder() @@ -33,7 +33,7 @@ func TestAccessLogCapturesImplicitOK(t *testing.T) { if !strings.Contains(logLine, "method=GET") { t.Fatalf("expected method in log, got %q", logLine) } - if !strings.Contains(logLine, "uri=\"/api/v1/users?ready=1\"") { + if !strings.Contains(logLine, "uri=\"/api/v1/channels/csgclaw/users?ready=1\"") { t.Fatalf("expected uri in log, got %q", logLine) } if !strings.Contains(logLine, "status=200") { @@ -52,7 +52,7 @@ func TestAccessLogCapturesExplicitStatus(t *testing.T) { })) rec := httptest.NewRecorder() - handler.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/users/u-1", nil)) + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, "/api/v1/channels/csgclaw/users/u-1", nil)) logLine := buf.String() if !strings.Contains(logLine, "status=204") { diff --git a/internal/server/http.go b/internal/server/http.go index d71e182d..fb5648d2 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -16,6 +16,7 @@ import ( "csgclaw/internal/hub" "csgclaw/internal/im" "csgclaw/internal/llm" + "csgclaw/internal/participant" "csgclaw/internal/team" "csgclaw/internal/upgrade" ) @@ -25,6 +26,7 @@ type Options struct { Service *agent.Service Hub *hub.Service Bot *bot.Service + Participant *participant.Service IM *im.Service IMBus *im.Bus BotBridge *im.BotBridge @@ -43,6 +45,7 @@ type Options struct { func newHandler(opts Options) *api.Handler { handler := api.NewHandlerWithBotAndAuth(opts.Service, opts.Bot, opts.IM, opts.IMBus, opts.BotBridge, opts.Feishu, opts.LLM, opts.AccessToken, opts.NoAuth) + handler.SetParticipantService(opts.Participant) handler.SetHubService(opts.Hub) handler.SetTeamService(opts.Team) handler.SetTeamAdapter(opts.TeamAdapter) @@ -59,7 +62,7 @@ func Run(opts Options) error { } handler := newHandler(opts) router := handler.Routes() - router.Handle("/*", uiHandler()) + router.Handle("/*", uiFallbackHandler()) httpServer := &http.Server{ Addr: opts.ListenAddr, diff --git a/internal/server/ui.go b/internal/server/ui.go index a0745daf..38bae83b 100644 --- a/internal/server/ui.go +++ b/internal/server/ui.go @@ -2,6 +2,7 @@ package server import ( "net/http" + "strings" webui "csgclaw/web" ) @@ -9,3 +10,20 @@ import ( func uiHandler() http.Handler { return webui.Handler() } + +func uiFallbackHandler() http.Handler { + return apiAwareFallbackHandler(uiHandler()) +} + +func apiAwareFallbackHandler(ui http.Handler) http.Handler { + if ui == nil { + ui = http.NotFoundHandler() + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + ui.ServeHTTP(w, r) + }) +} diff --git a/internal/server/ui_test.go b/internal/server/ui_test.go new file mode 100644 index 00000000..b452ca93 --- /dev/null +++ b/internal/server/ui_test.go @@ -0,0 +1,45 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestAPIAwareFallbackRejectsUnknownAPIPaths(t *testing.T) { + handler := apiAwareFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ui")) + })) + + tests := []struct { + method string + path string + }{ + {method: http.MethodGet, path: "/api"}, + {method: http.MethodGet, path: "/api/unknown/u-manager/events"}, + {method: http.MethodPost, path: "/api/v1/channels/csgclaw/bots"}, + } + + for _, tt := range tests { + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(tt.method, tt.path, nil)) + if rec.Code != http.StatusNotFound { + t.Fatalf("%s %s status = %d, want %d", tt.method, tt.path, rec.Code, http.StatusNotFound) + } + } +} + +func TestAPIAwareFallbackServesUIPaths(t *testing.T) { + handler := apiAwareFallbackHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ui")) + })) + + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/workspace", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + if rec.Body.String() != "ui" { + t.Fatalf("body = %q, want %q", rec.Body.String(), "ui") + } +} diff --git a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md index dfb8aa04..82e0b3eb 100644 --- a/internal/templates/embed/openclaw-manager/workspace/AGENTS.md +++ b/internal/templates/embed/openclaw-manager/workspace/AGENTS.md @@ -44,7 +44,7 @@ Suggested capability bullets (pick 3–4 that fit; keep the whole reply concise) e.g. "帮我创建一个 GitLab worker" - **Assign work** to existing workers in IM rooms and track multi-step handoffs — e.g. "把登录页 UI 交给 frontend worker 做" -- **Manage bots and rooms** — list workers, create rooms or Feishu groups, add +- **Manage participants and rooms** — list workers, create rooms or Feishu groups, add members — e.g. "列出当前所有 worker" - **Answer CSGClaw usage questions** — explain the manager vs worker model when asked @@ -77,14 +77,14 @@ or identity onboarding. - Prefer local workspace skills over external discovery. - **Agent creation first:** if the user wants to create/add/set up/provision an agent, bot, robot, or worker—or needs a new capability-specific worker—read - `skills/agent-creator/SKILL.md` immediately. Never run `bot create` without + `skills/agent-creator/SKILL.md` immediately. Never run `participant create --bind create` without `--from-template` for a new worker. - **Team orchestration second:** for multi-worker handoff when workers exist (or after `agent-creator` finishes), read `skills/agent-teams/SKILL.md` and use `csgclaw-cli team` (create tasks, plan, start). Each main task gets its own execution room when started. Use `skills/manager-worker-dispatch/SKILL.md` only as a legacy fallback outside team tasks. -- For CSGClaw room, bot, member, Feishu group/chat creation, or adding bots to +- For CSGClaw room, participant, member, Feishu group/chat creation, or adding participants to Feishu groups, read and use `skills/basics/SKILL.md` first and run `csgclaw-cli`. Do not conclude group creation is unsupported just because the native OpenClaw `feishu_chat` tool only supports read/query actions. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md index b0202479..ec0976b2 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-creator -description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + bot create --from-template with --env for secrets. Never run bot create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. +description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + participant create --type agent --bind create --from-template with --env for secrets. Never run participant create --bind create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. --- # Agent Creator @@ -11,7 +11,7 @@ Use `basics` only after create for room membership or IM mentions. Use `manager- ## Routing Gate (mandatory) -Before running **any** `csgclaw-cli bot create` for a **new** worker: +Before running **any** `csgclaw-cli participant create --type agent --bind create` for a **new** worker: 1. Read this skill first. 2. Run `csgclaw-cli --output json hub list` and pick a template (do not skip even if the user named a capability like GitLab). @@ -26,7 +26,7 @@ Use this skill when: - the user asks to create, add, set up, or provision an agent, bot, robot, or worker - the user names a capability (GitLab, frontend, QA, review, etc.) and needs a matching worker -- `bot list` shows no suitable available worker for the required capability +- `participant list` shows no suitable available worker for the required capability - dispatch needs a new worker (pause dispatch, complete provisioning here, then resume with `basics` + dispatch) Do **not** use this skill when: @@ -41,7 +41,7 @@ Never run a bare worker create like: ```bash # FORBIDDEN for new workers -csgclaw-cli bot create --name gitlab-worker --role worker --runtime openclaw_sandbox +csgclaw-cli participant create --type agent --bind create --name gitlab-worker --role worker --runtime openclaw_sandbox ``` Never tell the worker secrets in chat instead of `--env`. @@ -51,9 +51,9 @@ Never skip `hub list` / `hub get` because you think you already know the templat ## Workflow 1. Confirm the user wants a **new** worker (or dispatch lacks one). If an available worker already matches, stop and reuse it. -2. `csgclaw-cli bot list --channel ` — avoid duplicate names; ask reuse vs new if ambiguous. +2. `csgclaw-cli participant list --channel --type agent` — avoid duplicate names; ask reuse vs new if ambiguous. 3. `csgclaw-cli --output json hub list` — match by `name`, `description`, and `role`. -4. No match → say so plainly; do not fall back to bare `bot create`. +4. No match → say so plainly; do not fall back to bare `participant create --bind create`. 5. Multiple matches → short comparison; let the user choose. 6. `csgclaw-cli --output json hub get ` — read `image_env`. 7. Collect every `required=true` env with no `default`; never echo `secret=true` values. @@ -61,7 +61,7 @@ Never skip `hub list` / `hub get` because you think you already know the templat 9. Create: ```bash -csgclaw-cli bot create \ +csgclaw-cli participant create --type agent --bind create \ --name gitlab-worker \ --description "GitLab issue and MR worker" \ --role worker \ @@ -70,15 +70,15 @@ csgclaw-cli bot create \ --channel ``` -10. Report bot id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. +10. Report participant id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. ## Commands ```bash csgclaw-cli --output json hub list csgclaw-cli --output json hub get builtin.gitlab-worker -csgclaw-cli bot list --channel -csgclaw-cli bot create --from-template --env KEY=VALUE ... --channel +csgclaw-cli participant list --channel --type agent +csgclaw-cli participant create --type agent --bind create --from-template --env KEY=VALUE ... --channel ``` Template env vars with `default` are injected by the server; pass `--env` only for secrets and overrides. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml index 388f24ac..54483a8f 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml +++ b/internal/templates/embed/openclaw-manager/workspace/skills/agent-creator/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Agent Creator" short_description: "Mandatory hub-template worker provisioning" - default_prompt: "Use $agent-creator before any new bot create. Hub list, hub get, then bot create --from-template with --env." + default_prompt: "Use $agent-creator before any new worker create. Hub list, hub get, then participant create --type agent --bind create --from-template with --env." diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md index 7058af83..4a1245a3 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/basics/SKILL.md @@ -1,6 +1,6 @@ --- name: basics -description: Handle routine CSGClaw CLI administration for rooms, Feishu group/chat creation, bot listing, room members, and IM mentions. Use for list bots, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + bot create --from-template). +description: Handle routine CSGClaw CLI administration for rooms, Feishu group/chat creation, participant listing, room members, and IM mentions. Use for list participants, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + participant create --type agent --bind create --from-template). --- # CSGClaw CLI Basics @@ -15,13 +15,13 @@ This skill covers direct CLI actions such as: - create a room - create a Feishu group/chat through CSGClaw - list rooms -- list all bots +- list all participants - list room members - add a bot as a room member - send a message, including a message with a mention - check command help for the current CLI surface before assuming flags -Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `bot create --from-template`). +Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `participant create --type agent --bind create --from-template`). Do not use this skill when the task requires any of the following: @@ -45,10 +45,10 @@ For hub template selection and `--from-template` creation, use `agent-creator` i Create a room: ```bash -csgclaw-cli room create --title test-room --creator-id u-manager --member-ids u-manager,u-dev --channel +csgclaw-cli room create --title test-room --creator-id manager --member-ids manager,u-dev --channel csgclaw ``` -Use CSGClaw bot IDs in room, member, and message commands. For Feishu room creation, keep the same bot ID parameters; CSGClaw converts configured bot IDs to Feishu app IDs and sends them as `bot_id_list`. +Use CSGClaw participant IDs in CSGClaw-channel room, member, and message commands. The default manager participant is `manager`; its backing agent ID is `u-manager`. For Feishu room creation, use configured Feishu participant/app IDs such as `u-manager`; CSGClaw converts those to Feishu app IDs and sends them as `bot_id_list`. List rooms and check whether a room is direct: @@ -56,13 +56,13 @@ List rooms and check whether a room is direct: csgclaw-cli room list --channel ``` -List bots: +List participants: ```bash -csgclaw-cli bot list --channel +csgclaw-cli participant list --channel --type agent ``` -Create a bot. Always include `--description`: +Create a worker participant: ```bash # Do not use this for new workers. Use agent-creator with --from-template instead. @@ -77,7 +77,7 @@ csgclaw-cli member list --room-id oc_xxx --channel Add a bot into a non-direct room: ```bash -csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id u-manager --channel +csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id manager --channel csgclaw ``` If the current room is direct in the local `csgclaw` channel, do not try to add the bot directly. Create a new room that includes the current DM participants plus the new bot: @@ -85,9 +85,9 @@ If the current room is direct in the local `csgclaw` channel, do not try to add ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ - --channel + --creator-id manager \ + --member-ids manager,u-dev,u-alex \ + --channel csgclaw ``` For Feishu, keep the same bot ID parameters: @@ -103,7 +103,7 @@ csgclaw-cli room create \ Send a message with a mention. Use the mentioned bot ID for `--mention-id`: ```bash -csgclaw-cli message create --room-id oc_xxx --sender-id u-manager --content "Please take a look." --mention-id u-alex --channel +csgclaw-cli message create --room-id oc_xxx --sender-id manager --content "Please take a look." --mention-id u-alex --channel csgclaw ``` ## Notifying workers in IM (critical) @@ -112,13 +112,13 @@ Workers are configured with **`mention_only`**: they only process group messages | Do | Do not | |----|--------| -| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `bot list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | +| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `participant list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | | Verify delivery with `message list` — content must include `` | Assume a human-style `@` in prose wakes the worker | -| Run `bot list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | +| Run `participant list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | Minimal handoff flow: -1. `csgclaw-cli bot list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). +1. `csgclaw-cli participant list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). 2. `csgclaw-cli member list` — confirm the worker is in the room; `member create` if missing. 3. `csgclaw-cli message create` with `--mention-id` and the task body. 4. `csgclaw-cli message list` — confirm the stored message contains ``. @@ -130,10 +130,10 @@ Example worker handoff (replace room ID, worker ID, and channel): ```bash csgclaw-cli message create \ --room-id \ - --sender-id u-manager \ + --sender-id manager \ --mention-id u-alex \ --content "Please implement the login page changes we discussed." \ - --channel + --channel csgclaw ``` Do **not** post `@alex` plain text in the room instead of `--mention-id`. @@ -141,12 +141,12 @@ Do **not** post `@alex` plain text in the room instead of `--mention-id`. ## Operating Rules - Prefer direct `csgclaw-cli` commands over ad hoc HTTP calls. -- Use `bot list` before creating a new bot if the user may be referring to an existing one. -- When a **new** worker is needed, use `agent-creator`; do not run bare `bot create` from this skill. +- Use `participant list` before creating a new worker if the user may be referring to an existing one. +- When a **new** worker is needed, use `agent-creator`; do not run bare `participant create --bind create` from this skill. - Verify room membership with `member list` after adding a member when room presence matters. - A direct room cannot accept an added bot as a new member. Create a new room with `--member-ids` containing the existing DM bots and the new bot. -- For Feishu, prefer `room create --member-ids` for new groups after bot configs exist. Use `member create` only for an existing Feishu group; that path requires manager app scopes such as `im:chat.members:write_only` or `im:chat`. -- Keep `csgclaw-cli` parameters bot-facing across channels: use bot IDs such as `u-manager`, `u-dev`, and `u-alex`. +- For Feishu, prefer `room create --member-ids` for new groups after Feishu credentials exist. Use `member create` only for an existing Feishu group; that path requires manager app scopes such as `im:chat.members:write_only` or `im:chat`. +- Use participant IDs at the CLI boundary. For the local CSGClaw manager use `manager`; Feishu app/config examples may still use configured agent IDs such as `u-manager`. - Never notify a worker with plain-text `@name`; always use `message create --mention-id` and verify `` in `message list`. - Keep the response focused on the concrete CLI result instead of introducing external planning artifacts. - Hand off to `agent-teams` for multi-worker team orchestration; use `manager-worker-dispatch` only if the user explicitly needs tracker handoff outside team tasks. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md index 7b9aaadc..d3a86118 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/SKILL.md @@ -1,6 +1,6 @@ --- name: feishu -description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through csgclaw-cli bot config, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/OpenClaw bots. +description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through Feishu config API, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/OpenClaw bots. --- # Feishu @@ -36,9 +36,9 @@ The script uses Feishu/Lark's accounts registration flow: 4. poll with `action=poll`, `device_code=<...>`, `tp=ob_app` 5. when the user completes app creation, receive `client_id` and `client_secret` 6. map `client_id` -> CSGClaw `app_id`, and `client_secret` -> CSGClaw `app_secret` -7. immediately write the secret to CSGClaw through `csgclaw-cli bot config --channel feishu --set` without printing it +7. immediately write the secret to CSGClaw through `PUT /api/v1/channels/feishu/config` without printing it -Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. OpenClaw uses Feishu/Lark WebSocket mode for real inbound bot messages. CSGClaw's `/api/v1/channels/feishu/bots/{bot}/events` endpoint is an internal SSE bridge for CSGClaw manager-to-worker dispatch, not a Feishu public webhook. +Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. OpenClaw uses Feishu/Lark WebSocket mode for real inbound bot messages. CSGClaw's `/api/v1/channels/feishu/participants/{participant}/events` endpoint is an internal SSE bridge for CSGClaw manager-to-worker dispatch, not a Feishu public webhook. ## When to Use @@ -71,8 +71,8 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei - inside manager box: typically `~/.openclaw/workspace/skills/feishu` or your configured skill root - host repo path: `internal/templates/embed/openclaw-manager/workspace/skills/feishu` 4. Server build supports: - - `csgclaw-cli bot config --channel feishu --set/--get/--reload` - - `POST /api/v1/channels/feishu/bots` + - Feishu config API (`PUT`/`GET`/`POST /api/v1/channels/feishu/config`) + - `POST /api/v1/channels/feishu/participants` - `POST /api/v1/agents/{id}/recreate` ## Manager Group Permissions @@ -101,8 +101,8 @@ For existing Feishu groups, `csgclaw-cli member list` and `member create` requir 1. Never print `app_secret`, `client_secret`, access tokens, verification tokens, encryption keys, or connection strings. 2. If a secret must be represented in examples or summaries, write `[REDACTED]`. 3. The script must print only `app_secret: present` after finalize. -4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli bot config --channel feishu --set --app-secret-stdin`. -5. Verify with `csgclaw-cli bot config --channel feishu --get`, not by printing the secret. +4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `PUT /api/v1/channels/feishu/config`. +5. Verify with `GET /api/v1/channels/feishu/config?bot_id=`, not by printing the secret. ## Choose Target Bot @@ -162,12 +162,12 @@ By default, `finalize` will: 1. poll Feishu/Lark until credentials are available or timeout 2. receive `client_id/client_secret` -3. write `app_id/app_secret` to CSGClaw through `csgclaw-cli bot config` +3. write `app_id/app_secret` to CSGClaw through `Feishu config API` - for `u-manager`, overwrite global `admin_open_id` only with the registration `open_id` - for worker bots, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` 4. auto-reload channel config -5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/bots` -6. for worker targets, recreate the worker after bot ensure so the new Feishu env/files take effect +5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/participants` +6. for worker targets, recreate the worker after participant ensure so the new Feishu env/files take effect - if BoxLite reports `box with name '' already exists` while CSGClaw reports `agent "" not found`, stop and tell the user the host has a stale partial worker box; do not keep trying random API paths or host-only commands from inside manager 7. for manager targets, print a `csgclaw.action_card` JSON payload with a whitelisted `rebuild-manager` action; the CSGClaw Web chat message should render the button to complete the window-triggered manager bootstrap replace flow. 8. print JSON with `app_secret: present`, never the real secret @@ -178,7 +178,7 @@ For a worker, default finalize is usually enough: python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id ``` -Use an exec/tool timeout of at least 600 seconds for this command. For workers, finalize recreates the target worker after config reload and bot ensure; do not create a second worker or change the bot id. +Use an exec/tool timeout of at least 600 seconds for this command. For workers, finalize recreates the target worker after config reload and participant ensure; do not create a second worker or change the bot id. For manager, default finalize configures and ensures the bot, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. The click is handled by the browser and calls the manager bootstrap replace surface (`POST /api/v1/agents` with `{"id":"u-manager","replace":true}`), not the hazardous generic recreate route. @@ -215,36 +215,35 @@ If Feishu/Lark registration endpoint fails, expires, or tenant policy blocks sca - App ID, usually `cli_...` - App Secret, provided only through a secure path. -Use `csgclaw-cli bot config` to set manually: +Use the Feishu config API to set manually: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-dev","app_id":"cli_xxx","app_secret":"[REDACTED]"}' ``` -or: +For manager setup, include `admin_open_id`: ```bash -csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-file /secure/path/feishu_app_secret +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` ## CLI Workflow Used by Script -The script writes and reloads Feishu config through `csgclaw-cli bot config` because sandboxed skills should not edit host files directly or hand-roll config API calls. +The script writes and reloads Feishu config through `Feishu config API` because sandboxed skills should not edit host files directly or hand-roll config API calls. For `u-manager`, the script passes the registration `open_id` as the global `admin_open_id` while setting config and auto-reloading: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli --output json bot config --channel feishu --set \ - --bot-id u-manager \ - --app-id cli_xxx \ - --admin-open-id ou_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` Expected response shape: @@ -260,13 +259,13 @@ Expected response shape: } ``` -Ensure bot: +Ensure participant: ```bash -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +csgclaw-cli participant create --type agent --bind create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu ``` -Recreate the worker after config reload and bot ensure so the runtime picks up the updated Feishu credentials: +Recreate the worker after config reload and participant ensure so the runtime picks up the updated Feishu credentials: ```bash curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ @@ -275,12 +274,14 @@ curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ ## CLI Workflow for Manual Control -Use `csgclaw-cli bot config` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. +Use `Feishu config API` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. ```bash -csgclaw-cli bot config --channel feishu --get --bot-id u-dev -csgclaw-cli bot config --channel feishu --reload -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +curl -sS "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config?bot_id=u-dev" \ + -H "Authorization: Bearer [REDACTED]" +curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" +csgclaw-cli participant create --type agent --bind create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-dev ``` @@ -301,7 +302,7 @@ python /home/node/.openclaw/workspace/skills/feishu/scripts/feishu_register.py f Run the command with exec `timeout` at least `600`. -4. Confirm finalize recreated the worker after reload and bot ensure. +4. Confirm finalize recreated the worker after reload and participant ensure. 5. Tell the user to test from Feishu by messaging or @mentioning the bot. ## Manager One-Shot Recipe @@ -331,11 +332,11 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag 1. Using `csgclaw-cli agent ...`: lite CLI does not have agent commands. Use full `csgclaw` or API. 2. Running host-only `csgclaw` or `boxlite` commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale BoxLite boxes if needed. -3. Looking for removed `csgclaw channel ...` commands: Feishu config belongs to `csgclaw-cli bot config --channel feishu`. +3. Looking for removed `csgclaw channel ...` or `csgclaw-cli bot config ...` commands: Feishu config belongs to `/api/v1/channels/feishu/config`. 4. Creating the CSGClaw bot before writing/reloading Feishu config: this can create local placeholder identity. 5. Expecting reload to update an already-running OpenClaw box: recreate is still required. 6. Calling manager recreate from inside this manager-hosted skill: return the action card so the current window renders the rebuild button. -7. Checking `agent list` or `bot list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. +7. Checking `agent list` or `participant list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. 8. Printing secrets in summaries or logs: always mask as `[REDACTED]` or `present`. 9. Calling CSGClaw SSE endpoint a Feishu webhook: it is an internal CSGClaw-to-runtime bridge. 10. If Feishu changes the accounts registration endpoint or tenant policy blocks PersonalAgent creation, fall back to manual App ID/App Secret setup. @@ -346,8 +347,8 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag - [ ] `finalize` output shows `app_secret` only as `present`. - [ ] `finalize` configured `bot_id` and `app_id` in CSGClaw. - [ ] CSGClaw channel config was reloaded. -- [ ] CSGClaw bot exists with `channel=feishu`. -- [ ] Worker agents are recreated after config reload and bot ensure. +- [ ] CSGClaw participant exists with `channel=feishu`. +- [ ] Worker agents are recreated after config reload and participant ensure. - [ ] New worker finalize was run with a tool timeout of at least 600 seconds. - [ ] Manager finalize returned a raw `csgclaw.action_card` JSON object with `rebuild-manager` action metadata for the web button. - [ ] No manager-hosted command called the generic manager recreate endpoint or any host-side manager rebuild command. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py index e900598d..e9350b30 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py @@ -269,7 +269,7 @@ def build_parser() -> argparse.ArgumentParser: finalize.add_argument("--registration-id", required=True) finalize.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) finalize.add_argument("--no-configure", action="store_true", help="Do not write CSGClaw config; for debugging only, still never prints secret") - finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/bots") + finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/participants") finalize.add_argument("--role", choices=["worker", "manager"], default="", help="Override role for ensure/recreate logic") finalize.add_argument("--bot-name", default="", help="Override bot name for ensure") finalize.add_argument("--description", default="", help="Override bot description for ensure") diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py index 677475cb..460c12c9 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -99,22 +99,15 @@ def csgclaw_cli_json(args, cli_args: list[str], input_text: Optional[str] = None def configure_csgclaw(args, state: dict, result: dict) -> dict: bot_id = state["bot_id"] - cli_args = [ - "bot", - "config", - "--channel", - "feishu", - "--set", - "--bot-id", - bot_id, - "--app-id", - result["app_id"], - "--app-secret-stdin", - ] + payload = { + "bot_id": bot_id, + "app_id": result["app_id"], + "app_secret": result["app_secret"], + } candidate_admin_open_id = str(result.get("open_id") or "").strip() if bot_id == "u-manager" and candidate_admin_open_id: - cli_args.extend(["--admin-open-id", candidate_admin_open_id]) - response = csgclaw_cli_json(args, cli_args, input_text=result["app_secret"] + "\n") or {} + payload["admin_open_id"] = candidate_admin_open_id + response = api_json(args, "PUT", "/api/v1/channels/feishu/config", payload) or {} if bot_id == "u-manager": if candidate_admin_open_id: response["admin_open_id"] = candidate_admin_open_id @@ -140,12 +133,22 @@ def ensure_bot(args, state: dict, result: dict) -> Optional[dict]: description = args.description or state.get("description") or f"{name} Feishu {role} agent" payload = { "id": bot_id, + "type": "agent", "name": name, - "description": description, - "role": role, - "channel": "feishu", + "channel_app_ref": result.get("app_id") or state.get("app_id") or "", + "channel_user": {"ref": bot_id, "kind": "local_user_id"}, + "agent_binding": { + "mode": "create", + "agent_id": bot_id, + "agent": { + "id": bot_id, + "name": name, + "description": description, + "role": role, + }, + }, } - return api_json(args, "POST", f"/api/v1/channels/feishu/bots", payload) + return api_json(args, "POST", "/api/v1/channels/feishu/participants", payload) def worker_box_conflict_message(bot_id: str, name: str) -> str: @@ -171,10 +174,10 @@ def is_same_bot_name_conflict(exc: RuntimeError, bot_id: str) -> bool: def bot_exists(args, bot_id: str) -> bool: - bots = csgclaw_cli_json(args, ["bot", "list", "--channel", "feishu"]) - if not isinstance(bots, list): - raise RuntimeError(f"csgclaw-cli bot list returned unexpected JSON: {bots!r}") - return any(str(bot.get("id") or "").strip() == bot_id for bot in bots if isinstance(bot, dict)) + participants = csgclaw_cli_json(args, ["participant", "list", "--channel", "feishu", "--type", "agent"]) + if not isinstance(participants, list): + raise RuntimeError(f"csgclaw-cli participant list returned unexpected JSON: {participants!r}") + return any(str(item.get("id") or "").strip() == bot_id for item in participants if isinstance(item, dict)) def maybe_recreate(args, state: dict, worker_existed_before_ensure: Optional[bool] = None) -> Optional[dict]: diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py index 7b5de372..6b8a766a 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/feishu/scripts/tests/test_manager_action_card.py @@ -55,7 +55,7 @@ def test_worker_finalize_continues_recreate_when_same_bot_already_exists(self): def fake_ensure_bot(args, state, result): raise RuntimeError( - 'CSGClaw API POST /api/v1/channels/feishu/bots failed: HTTP 400: ' + 'CSGClaw API POST /api/v1/channels/feishu/participants failed: HTTP 400: ' 'bot name "web-dev" already exists in channel "feishu" with id "u-web-dev"' ) diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md index 2c9ff00f..43b1fb55 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md @@ -23,22 +23,22 @@ Do not use this skill for: - collecting template `image_env` values - creating agents with `--from-template` -For those flows, use `agent-creator`. Never create a new worker with bare `bot create` from dispatch. +For those flows, use `agent-creator`. Never create a new worker with bare `participant create --bind create` from dispatch. ## Mandatory Dispatch Order When a user asks for manager-led coordination (especially GitLab planning, issue queries, breakdown, assignment, or execution handoff), follow this order and do not skip steps: 1. Read this skill first before any domain execution. -2. Check existing workers first (`bot list`) and prefer reusing a capable available worker. +2. Check existing workers first (`participant list`) and prefer reusing a capable available worker. 3. Ensure the selected worker is a member of the target room (add if missing). 4. If no suitable worker exists or the matching one is `unavailable`: - - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `bot create --from-template` + `--env`), then use `basics` to add the worker to the room. + - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `participant create --type agent --bind create --from-template` + `--env`), then use `basics` to add the worker to the room. - **Existing worker unavailable:** use `basics` / recreate paths for that bot id only; do not bare-create a replacement. 5. Dispatch the task to the worker in-room after membership is confirmed. Do not start with repo/code exploration, web fetch/search, or manager self-execution when the task is delegable to an existing or creatable worker. -Do not skip `bot list` by assuming worker availability from memory or prior turns. +Do not skip `participant list` by assuming worker availability from memory or prior turns. ## Fast Path @@ -60,7 +60,7 @@ Do not inspect or modify project implementation files before dispatch unless you ## Workflow 1. Break the admin request into concrete deliverables. -2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`bot list`) and reuse by matching `description`. +2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`participant list`) and reuse by matching `description`. 3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing bot (unavailable) before dispatch. 4. Use the `basics` skill to ensure every required worker has joined the target room, then verify the full required worker set. 5. Dispatch the user task to selected workers in-room after membership checks pass. @@ -167,7 +167,7 @@ Use the `basics` skill whenever this workflow needs any of these supporting oper - add a worker into the room - verify room membership before tracking -When a **new** worker is required, use `agent-creator` instead of bare `bot create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. +When a **new** worker is required, use `agent-creator` instead of bare `participant create --bind create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. ## Tracking Script Usage @@ -214,7 +214,7 @@ If you need to direct the human user to the project files on their Mac, point th Use this when exactly one worker should act (for example install a registry skill via `skill-installer`, run one GitLab job) and you do **not** need multi-step `todo.json` sequencing. -1. Use the `basics` skill: `bot list`, confirm room membership, then `message create --mention-id `. +1. Use the `basics` skill: `participant list`, confirm room membership, then `message create --mention-id `. 2. Do **not** reply in the room with plain `@worker-name` after registry skill discovery or other tools — that does not wake workers under `mention_only`. 3. Verify with `csgclaw-cli message list` that the dispatch message contains ``. 4. Use `start-tracking` only when multiple workers or ordered handoff is required. diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md index 8d88fcb5..c5ca56a8 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md @@ -9,7 +9,7 @@ The skill and CLI use `room` as the user-facing term. Where the underlying HTTP - `CSGCLAW_BASE_URL`: Preferred when the script runs inside a CSGClaw box. - `CSGCLAW_ACCESS_TOKEN`: Preferred bearer token when the script runs inside a CSGClaw box. - `MANAGER_API_BASE_URL`: Optional. Default: `http://127.0.0.1:18080` -- `MANAGER_API_TOKEN`: Optional bearer token. Required for `/api/bots/*` when the server enables auth. +- `MANAGER_API_TOKEN`: Optional bearer token. Required for participant APIs when the server enables auth. - `MANAGER_API_TIMEOUT`: Optional request timeout in seconds. Default: `30` ## Local Config @@ -24,7 +24,7 @@ When available, load the CSGClaw API settings from `~/.openclaw/openclaw.json`: ### Dispatch task by bot message - Method: `POST` -- Path: `/api/bots/{bot_id}/messages/send` +- Path: `/api/v1/channels/csgclaw/participants/{participant_id}/messages` - Request body: ```json diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index cb76060f..b04055c0 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -513,7 +513,11 @@ def _mock_response( "payload": payload, } - if method == "POST" and path.startswith("/api/bots/") and path.endswith("/messages/send"): + if ( + method == "POST" + and path.startswith("/api/v1/channels/csgclaw/participants/") + and path.endswith("/messages") + ): result["message_id"] = "dry-run-message-id" return result diff --git a/internal/templates/embed/picoclaw-manager/workspace/AGENT.md b/internal/templates/embed/picoclaw-manager/workspace/AGENT.md index fbab58fa..deb6ce1c 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/AGENT.md +++ b/internal/templates/embed/picoclaw-manager/workspace/AGENT.md @@ -42,7 +42,7 @@ be practical, accurate, and efficient. - Manager may execute domain work directly only when no suitable worker is available, or when the human explicitly requires manager-only execution. - When direct execution is used as fallback, manager should explain why dispatch was not possible. - Dispatch means waking a worker with a real IM mention (`csgclaw-cli message create --mention-id ` so the message contains `...`). Do **not** type plain-text `@worker-name` in the room or PicoClaw `message` tool content; workers use `mention_only` and will ignore it. Manager-side `subagent` calls are not valid worker dispatch. -- For work that should be **handed off to a worker** (actionable, tool-heavy, or clearly matching a worker’s skills from `bot list` / descriptions): do **not** open with `web_fetch` or `web_search` to do the worker’s job yourself. For multi-worker team workflows, follow `workspace/skills/agent-teams/SKILL.md` (plan/start via `csgclaw-cli team` and the Tasks API) so dispatch, claim, and status stay on the server task state. Use `manager-worker-dispatch` only when the user explicitly needs tracker-driven sequential handoff outside team tasks. If a **new** worker is needed, use `agent-creator` to provision from hub templates before dispatch continues. Use web tools only for manager-only questions, lightweight clarification, or after you have explained why dispatch is blocked. +- For work that should be **handed off to a worker** (actionable, tool-heavy, or clearly matching a worker's skills from `participant list` / descriptions): do **not** open with `web_fetch` or `web_search` to do the worker's job yourself. For multi-worker team workflows, follow `workspace/skills/agent-teams/SKILL.md` (plan/start via `csgclaw-cli team` and the Tasks API) so dispatch, claim, and status stay on the server task state. Use `manager-worker-dispatch` only when the user explicitly needs tracker-driven sequential handoff outside team tasks. If a **new** worker is needed, use `agent-creator` to provision from hub templates before dispatch continues. Use web tools only for manager-only questions, lightweight clarification, or after you have explained why dispatch is blocked. ## Casual messages and CSGClaw onboarding @@ -58,7 +58,7 @@ Suggested capability bullets (pick 3–4 that fit; keep the whole reply concise) - **Create workers** from hub templates (GitLab, frontend, QA, review, etc.) — e.g. "帮我创建一个 GitLab worker" - **Assign work** to existing workers in IM rooms and track multi-step handoffs — e.g. "把登录页 UI 交给 frontend worker 做" -- **Manage bots and rooms** — list workers, create rooms, add members — e.g. "列出当前所有 worker" +- **Manage participants and rooms** — list workers, create rooms, add members — e.g. "列出当前所有 worker" - **Answer CSGClaw usage questions** — explain the manager vs worker model when asked Do **not** list skill search or install in the welcome message. Workers install skills themselves via `skill-installer`; manager only dispatches that work when the user asks. @@ -67,7 +67,7 @@ Keep the intro to roughly **6–10 lines** unless the user asks for more detail. ## Skill loading priority -1. **Agent creation first.** If the user wants to create/add/set up/provision an agent, bot, robot, or worker—or names a capability that needs a new worker (GitLab, frontend, QA, etc.)—read `workspace/skills/agent-creator/SKILL.md` **immediately** and follow it. Do **not** run `bot create` without `--from-template`. Skip dispatch until provisioning completes or an existing worker is reused. +1. **Agent creation first.** If the user wants to create/add/set up/provision an agent, bot, robot, or worker—or names a capability that needs a new worker (GitLab, frontend, QA, etc.)—read `workspace/skills/agent-creator/SKILL.md` **immediately** and follow it. Do **not** run `participant create --bind create` without `--from-template`. Skip dispatch until provisioning completes or an existing worker is reused. 2. **Team orchestration second.** For executable multi-worker handoff when workers already exist (or after `agent-creator` finishes), read `workspace/skills/agent-teams/SKILL.md` and use `csgclaw-cli team` (create tasks, plan, start). Each main task gets its own execution room when started; workers are woken there via structured mentions from team dispatch. Use `workspace/skills/manager-worker-dispatch/SKILL.md` only as a legacy fallback when team tasks are not appropriate. - Only after dispatch routing decides execution mode may manager read a domain skill (for worker dispatch constraints or fallback direct execution). - Before planning or dispatching a task, first list local skills under `workspace/skills` and choose from them. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md index 81409a2b..fc601512 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/SKILL.md @@ -1,6 +1,6 @@ --- name: agent-creator -description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, backend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + bot create --from-template with --env for secrets. Never run bot create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. +description: Mandatory skill for provisioning any new CSGClaw agent, bot, robot, or worker. Use immediately when the user asks to create, add, set up, or provision an agent/bot/worker (including GitLab, frontend, backend, QA, or other specialized workers), when dispatch needs a missing worker, or when asking which hub template fits. Always hub list + match + hub get + participant create --type agent --bind create --from-template with --env for secrets. Never run participant create --bind create without --from-template for a new worker. Do NOT use for task dispatch to existing workers or todo.json tracking only. --- # Agent Creator @@ -11,7 +11,7 @@ Use `basics` only after create for room membership or IM mentions. Use `manager- ## Routing Gate (mandatory) -Before running **any** `csgclaw-cli bot create` for a **new** worker: +Before running **any** `csgclaw-cli participant create --type agent --bind create` for a **new** worker: 1. Read this skill first. 2. Run `csgclaw-cli --output json hub list` and pick a template (do not skip even if the user named a capability like GitLab). @@ -26,7 +26,7 @@ Use this skill when: - the user asks to create, add, set up, or provision an agent, bot, robot, or worker - the user names a capability (GitLab, frontend, backend, QA, review, etc.) and needs a matching worker -- `bot list` shows no suitable available worker for the required capability +- `participant list` shows no suitable available worker for the required capability - dispatch needs a new worker (pause dispatch, complete provisioning here, then resume with `basics` + dispatch) Do **not** use this skill when: @@ -41,7 +41,7 @@ Never run a bare worker create like: ```bash # FORBIDDEN for new workers -csgclaw-cli bot create --name gitlab-worker --role worker --runtime picoclaw_sandbox +csgclaw-cli participant create --type agent --bind create --name gitlab-worker --role worker --runtime picoclaw_sandbox ``` Never tell the worker secrets in chat instead of `--env`. @@ -51,9 +51,9 @@ Never skip `hub list` / `hub get` because you think you already know the templat ## Workflow 1. Confirm the user wants a **new** worker (or dispatch lacks one). If an available worker already matches, stop and reuse it. -2. `csgclaw-cli bot list --channel ` — avoid duplicate names; ask reuse vs new if ambiguous. +2. `csgclaw-cli participant list --channel --type agent` — avoid duplicate names; ask reuse vs new if ambiguous. 3. `csgclaw-cli --output json hub list` — match by `name`, `description`, and `role`. -4. No match → say so plainly; do not fall back to bare `bot create`. +4. No match → say so plainly; do not fall back to bare `participant create --bind create`. 5. Multiple matches → short comparison; let the user choose. 6. `csgclaw-cli --output json hub get ` — read `image_env`. 7. Collect every `required=true` env with no `default`; never echo `secret=true` values. @@ -61,7 +61,7 @@ Never skip `hub list` / `hub get` because you think you already know the templat 9. Create: ```bash -csgclaw-cli bot create \ +csgclaw-cli participant create --type agent --bind create \ --name gitlab-worker \ --description "GitLab issue and MR worker" \ --role worker \ @@ -70,15 +70,15 @@ csgclaw-cli bot create \ --channel ``` -10. Report bot id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. +10. Report participant id, template id, and env status. Use `basics` for `member create` if the user wants the worker in the room. Do **not** auto-dispatch unless asked. ## Commands ```bash csgclaw-cli --output json hub list csgclaw-cli --output json hub get builtin.gitlab-worker -csgclaw-cli bot list --channel -csgclaw-cli bot create --from-template --env KEY=VALUE ... --channel +csgclaw-cli participant list --channel --type agent +csgclaw-cli participant create --type agent --bind create --from-template --env KEY=VALUE ... --channel ``` Template env vars with `default` are injected by the server; pass `--env` only for secrets and overrides. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml index 388f24ac..54483a8f 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/agent-creator/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Agent Creator" short_description: "Mandatory hub-template worker provisioning" - default_prompt: "Use $agent-creator before any new bot create. Hub list, hub get, then bot create --from-template with --env." + default_prompt: "Use $agent-creator before any new worker create. Hub list, hub get, then participant create --type agent --bind create --from-template with --env." diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md index 090e857a..68828fec 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/basics/SKILL.md @@ -1,6 +1,6 @@ --- name: basics -description: Handle routine CSGClaw CLI administration for rooms, bot listing, room members, and IM mentions. Use for list bots, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + bot create --from-template). +description: Handle routine CSGClaw CLI administration for rooms, participant listing, room members, and IM mentions. Use for list participants, member create, message create, and room operations. Do NOT use for creating a new worker—use agent-creator instead (hub list + participant create --type agent --bind create --from-template). --- # CSGClaw CLI Basics @@ -14,13 +14,13 @@ This skill covers direct CLI actions such as: - create a room - list rooms -- list all bots +- list all participants - list room members - add a bot as a room member - send a message, including a message with a mention - check command help for the current CLI surface before assuming flags -Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `bot create --from-template`). +Do **not** use this skill to **create a new worker**. For any new agent/bot/worker provisioning, use `agent-creator` (`hub list`, `hub get`, `participant create --type agent --bind create --from-template`). Do not use this skill when the task requires any of the following: @@ -44,10 +44,10 @@ For hub template selection and `--from-template` creation, use `agent-creator` i Create a room: ```bash -csgclaw-cli room create --title test-room --creator-id u-manager --member-ids u-manager,u-dev --channel +csgclaw-cli room create --title test-room --creator-id manager --member-ids manager,u-dev --channel csgclaw ``` -Use CSGClaw bot IDs in room, member, and message commands. +Use CSGClaw participant IDs in CSGClaw-channel room, member, and message commands. The default manager participant is `manager`; its backing agent ID is `u-manager`. List rooms and check whether a room is direct: @@ -55,13 +55,13 @@ List rooms and check whether a room is direct: csgclaw-cli room list --channel ``` -List bots: +List participants: ```bash -csgclaw-cli bot list --channel +csgclaw-cli participant list --channel --type agent ``` -Create a bot. Always include `--description`: +Create a worker participant: ```bash # Do not use this for new workers. Use agent-creator with --from-template instead. @@ -76,7 +76,7 @@ csgclaw-cli member list --room-id oc_xxx --channel Add a bot into a non-direct room: ```bash -csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id u-manager --channel +csgclaw-cli member create --room-id oc_xxx --user-id u-alex --inviter-id manager --channel csgclaw ``` If the current room is direct in the local `csgclaw` channel, do not try to add the bot directly. Create a new room that includes the current DM participants plus the new bot: @@ -84,12 +84,12 @@ If the current room is direct in the local `csgclaw` channel, do not try to add ```bash csgclaw-cli room create \ --title "manager-dev-alex" \ - --creator-id u-manager \ - --member-ids u-manager,u-dev,u-alex \ - --channel + --creator-id manager \ + --member-ids manager,u-dev,u-alex \ + --channel csgclaw ``` -For Feishu, keep the same bot ID parameters: +For Feishu, use the configured Feishu participant/app IDs: ```bash csgclaw-cli room create \ @@ -102,7 +102,7 @@ csgclaw-cli room create \ Send a message with a mention. Use the mentioned bot ID for `--mention-id`: ```bash -csgclaw-cli message create --room-id oc_xxx --sender-id u-manager --content "Please take a look." --mention-id u-alex --channel +csgclaw-cli message create --room-id oc_xxx --sender-id manager --content "Please take a look." --mention-id u-alex --channel csgclaw ``` ## Notifying workers in IM (critical) @@ -111,13 +111,13 @@ Workers are configured with **`mention_only`**: they only process group messages | Do | Do not | |----|--------| -| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `bot list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | +| `csgclaw-cli message create ... --mention-id u-gitlab-worker` (ID from `participant list`) | Type `@gitlab-worker` or `@worker-name` in `--content`, room replies, or the PicoClaw `message` tool | | Verify delivery with `message list` — content must include `` | Assume a human-style `@` in prose wakes the worker | -| Run `bot list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | +| Run `participant list` and `member list` before the first dispatch | Skip membership checks and post assignment text only | Minimal handoff flow: -1. `csgclaw-cli bot list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). +1. `csgclaw-cli participant list` — resolve the worker **bot ID** (e.g. `u-gitlab-worker`, not the display name). 2. `csgclaw-cli member list` — confirm the worker is in the room; `member create` if missing. 3. `csgclaw-cli message create` with `--mention-id` and the task body. 4. `csgclaw-cli message list` — confirm the stored message contains ``. @@ -129,10 +129,10 @@ Example worker handoff (replace room ID, worker ID, and channel): ```bash csgclaw-cli message create \ --room-id \ - --sender-id u-manager \ + --sender-id manager \ --mention-id u-alex \ --content "Please implement the login page changes we discussed." \ - --channel + --channel csgclaw ``` Do **not** post `@alex` plain text in the room instead of `--mention-id`. @@ -140,11 +140,11 @@ Do **not** post `@alex` plain text in the room instead of `--mention-id`. ## Operating Rules - Prefer direct `csgclaw-cli` commands over ad hoc HTTP calls. -- Use `bot list` before creating a new bot if the user may be referring to an existing one. -- When a **new** worker is needed, use `agent-creator`; do not run bare `bot create` from this skill. +- Use `participant list` before creating a new worker if the user may be referring to an existing one. +- When a **new** worker is needed, use `agent-creator`; do not run bare `participant create --bind create` from this skill. - Verify room membership with `member list` after adding a member when room presence matters. - A direct room cannot accept an added bot as a new member. Create a new room with `--member-ids` containing the existing DM bots and the new bot. -- Keep `csgclaw-cli` parameters bot-facing across channels: use bot IDs such as `u-manager`, `u-dev`, and `u-alex`. +- Use participant IDs at the CLI boundary. For the local CSGClaw manager use `manager`; Feishu app/config examples may still use configured agent IDs such as `u-manager`. - Never notify a worker with plain-text `@name`; always use `message create --mention-id` and verify `` in `message list`. - Keep the response focused on the concrete CLI result instead of introducing external planning artifacts. - Hand off to `agent-teams` for multi-worker team orchestration; use `manager-worker-dispatch` only if the user explicitly needs tracker handoff outside team tasks. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md index e9f5b458..fa2028b4 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/SKILL.md @@ -1,6 +1,6 @@ --- name: feishu -description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through csgclaw-cli bot config, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/PicoClaw bots. +description: Configure and troubleshoot CSGClaw Feishu/Lark channel credentials for manager or worker bots. Use when the Manager needs to generate a bot creation URL or QR code, collect App ID/App Secret through registration, write and reload channel config through Feishu config API, ensure or recreate agents, or debug Feishu messages not reaching CSGClaw/PicoClaw bots. --- # Feishu @@ -36,9 +36,9 @@ The script uses Feishu/Lark's accounts registration flow: 4. poll with `action=poll`, `device_code=<...>`, `tp=ob_app` 5. when the user completes app creation, receive `client_id` and `client_secret` 6. map `client_id` -> CSGClaw `app_id`, and `client_secret` -> CSGClaw `app_secret` -7. immediately write the secret to CSGClaw through `csgclaw-cli bot config --channel feishu --set` without printing it +7. immediately write the secret to CSGClaw through `PUT /api/v1/channels/feishu/config` without printing it -Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. PicoClaw uses Feishu/Lark WebSocket mode for inbound bot messages. CSGClaw's `/api/v1/channels/feishu/bots/{bot}/events` endpoint is an internal SSE bridge for PicoClaw workers, not a Feishu public webhook. +Do not add or require a public Feishu Open Platform HTTP webhook as the main inbound path. PicoClaw uses Feishu/Lark WebSocket mode for inbound bot messages. CSGClaw's `/api/v1/channels/feishu/participants/{participant}/events` endpoint is an internal SSE bridge for PicoClaw workers, not a Feishu public webhook. ## When to Use @@ -71,8 +71,8 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei - inside manager box: typically `~/.picoclaw/workspace/skills/feishu` or your configured skill root - host repo path: `internal/templates/embed/picoclaw-manager/workspace/skills/feishu` 4. Server build supports: - - `csgclaw-cli bot config --channel feishu --set/--get/--reload` - - `POST /api/v1/channels/feishu/bots` + - Feishu config API (`PUT`/`GET`/`POST /api/v1/channels/feishu/config`) + - `POST /api/v1/channels/feishu/participants` - `POST /api/v1/agents/{id}/recreate` ## Safe Credential Rules @@ -80,8 +80,8 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei 1. Never print `app_secret`, `client_secret`, access tokens, verification tokens, encryption keys, or connection strings. 2. If a secret must be represented in examples or summaries, write `[REDACTED]`. 3. The script must print only `app_secret: present` after finalize. -4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli bot config --channel feishu --set --app-secret-stdin`. -5. Verify with `csgclaw-cli bot config --channel feishu --get`, not by printing the secret. +4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `PUT /api/v1/channels/feishu/config`. +5. Verify with `GET /api/v1/channels/feishu/config?bot_id=`, not by printing the secret. ## Choose Target Bot @@ -101,9 +101,9 @@ Example normalization: For worker flow, check whether the Feishu bot already exists before deciding recreate: ```bash -./csgclaw-cli --output json bot list --channel feishu +./csgclaw-cli --output json participant list --channel feishu --type agent ``` -Treat a row whose `id` equals `$bot_id` as an existing Feishu bot (needs recreate after ensure), and no matching row as missing (skip recreate, let bot ensure create it). +Treat a row whose `id` equals `$bot_id` as an existing Feishu bot (needs recreate after ensure), and no matching row as missing (skip recreate, let participant ensure create it). ## Primary QR/Launcher Flow @@ -145,14 +145,14 @@ By default, `finalize` will: 1. poll Feishu/Lark until credentials are available or timeout 2. receive `client_id/client_secret` -3. write `app_id/app_secret` to CSGClaw through `csgclaw-cli bot config` +3. write `app_id/app_secret` to CSGClaw through `Feishu config API` - for `u-manager`, overwrite global `admin_open_id` only with the registration `open_id` - for worker bots, ignore registration `open_id` and do not read, preserve, write, or report `admin_open_id` 4. auto-reload channel config -5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/bots` -6. for worker targets, check whether the Feishu bot already existed before ensure using `./csgclaw-cli --output json bot list --channel feishu`: +5. ensure the CSGClaw bot through `POST /api/v1/channels/feishu/participants` +6. for worker targets, check whether the Feishu bot already existed before ensure using `./csgclaw-cli --output json participant list --channel feishu --type agent`: - existing bot: recreate its worker so the new Feishu env takes effect - - missing bot: let bot ensure create it with the already-reloaded config, then skip redundant recreate + - missing bot: let participant ensure create it with the already-reloaded config, then skip redundant recreate - if BoxLite reports `box with name '' already exists` while CSGClaw reports `agent "" not found`, stop and tell the user the host has a stale partial worker box; do not keep trying random API paths or host-only commands from inside manager 7. for manager targets, print a `csgclaw.action_card` JSON payload with a whitelisted `rebuild-manager` action; the CSGClaw Web chat message should render the button to complete the window-triggered manager bootstrap replace flow. 8. print JSON with `app_secret: present`, never the real secret @@ -163,9 +163,9 @@ For a worker, default finalize is usually enough: python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py finalize --registration-id ``` -Use an exec/tool timeout of at least 600 seconds for this command. Before deciding recreate, use `./csgclaw-cli --output json bot list --channel feishu`: +Use an exec/tool timeout of at least 600 seconds for this command. Before deciding recreate, use `./csgclaw-cli --output json participant list --channel feishu --type agent`: - matching `id`: recreate existing worker - - no matching `id`: skip recreate, because bot ensure has already created it + - no matching `id`: skip recreate, because participant ensure has already created it If `worker_existed_before_ensure` is `true`, the script recreates the existing worker after config reload; do not create a second worker or change the bot id. For manager, default finalize configures and ensures the bot, then prints a structured action card. Return the JSON object exactly as the chat message content: no leading sentence, no Markdown table, no bullet list, no ```json fence, and no explanatory wrapper. The CSGClaw Web frontend will render a "重建 Manager" button. @@ -203,36 +203,35 @@ If Feishu/Lark registration endpoint fails, expires, or tenant policy blocks sca - App ID, usually `cli_...` - App Secret, provided only through a secure path. -Use `csgclaw-cli bot config` to set manually: +Use the Feishu config API to set manually: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-dev","app_id":"cli_xxx","app_secret":"[REDACTED]"}' ``` -or: +For manager setup, include `admin_open_id`: ```bash -csgclaw-cli bot config --channel feishu --set \ - --bot-id u-dev \ - --app-id cli_xxx \ - --app-secret-file /secure/path/feishu_app_secret +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` ## CLI Workflow Used by Script -The script writes and reloads Feishu config through `csgclaw-cli bot config` because sandboxed skills should not edit host files directly or hand-roll config API calls. +The script writes and reloads Feishu config through `Feishu config API` because sandboxed skills should not edit host files directly or hand-roll config API calls. For `u-manager`, the script passes the registration `open_id` as the global `admin_open_id` while setting config and auto-reloading: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli --output json bot config --channel feishu --set \ - --bot-id u-manager \ - --app-id cli_xxx \ - --admin-open-id ou_xxx \ - --app-secret-stdin +curl -sS -X PUT "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" \ + -H "Content-Type: application/json" \ + -d '{"bot_id":"u-manager","app_id":"cli_xxx","app_secret":"[REDACTED]","admin_open_id":"ou_xxx"}' ``` Expected response shape: @@ -248,13 +247,13 @@ Expected response shape: } ``` -Ensure bot: +Ensure participant: ```bash -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +csgclaw-cli participant create --type agent --bind create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu ``` -Recreate existing worker only if `./csgclaw-cli --output json bot list --channel feishu` showed `u-dev` before ensure; if the bot was missing, the bot ensure step creates it with the already-reloaded config and this recreate call is skipped: +Recreate existing worker only if `./csgclaw-cli --output json participant list --channel feishu --type agent` showed `u-dev` before ensure; if the bot was missing, the participant ensure step creates it with the already-reloaded config and this recreate call is skipped: ```bash curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ @@ -263,12 +262,14 @@ curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/agents/u-dev/recreate" \ ## CLI Workflow for Manual Control -Use `csgclaw-cli bot config` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. +Use `Feishu config API` for channel config. Use the helper script or the backend recreate API for agent recreate, because lite `csgclaw-cli` does not expose agent commands and manager boxes usually do not have full `csgclaw`. ```bash -csgclaw-cli bot config --channel feishu --get --bot-id u-dev -csgclaw-cli bot config --channel feishu --reload -csgclaw-cli bot create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu +curl -sS "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config?bot_id=u-dev" \ + -H "Authorization: Bearer [REDACTED]" +curl -sS -X POST "$CSGCLAW_BASE_URL/api/v1/channels/feishu/config" \ + -H "Authorization: Bearer [REDACTED]" +csgclaw-cli participant create --type agent --bind create --id u-dev --name dev --description "dev worker agent" --role worker --channel feishu python /home/picoclaw/.picoclaw/workspace/skills/feishu/scripts/feishu_register.py recreate-agent --bot-id u-dev ``` @@ -292,11 +293,11 @@ Run the command with exec `timeout` at least `600`. 4. Confirm existing Feishu bot before taking recreate path: ```bash -./csgclaw-cli --output json bot list --channel feishu +./csgclaw-cli --output json participant list --channel feishu --type agent ``` If the list contains ``, the manager can trigger recreate flow for this worker after reload. -If the list does not contain ``, skip recreate and let bot ensure creation stand. +If the list does not contain ``, skip recreate and let participant ensure creation stand. 5. Tell the user to test from Feishu by messaging or @mentioning the bot. @@ -327,11 +328,11 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag 1. Using `csgclaw-cli agent ...`: lite CLI does not have agent commands. Use full `csgclaw` or API. 2. Running host-only `csgclaw` or `boxlite` commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale BoxLite boxes if needed. -3. Looking for removed `csgclaw channel ...` commands: Feishu config belongs to `csgclaw-cli bot config --channel feishu`. +3. Looking for removed `csgclaw channel ...` or `csgclaw-cli bot config ...` commands: Feishu config belongs to `/api/v1/channels/feishu/config`. 4. Creating the CSGClaw bot before writing/reloading Feishu config: this can create local placeholder identity. 5. Expecting reload to update an already-running PicoClaw box: recreate is still required. 6. Calling manager recreate from inside this manager-hosted skill: return the action card so the current window renders the rebuild button. -7. Checking `agent list` or `bot list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. +7. Checking `agent list` or `participant list` after manager recreate and treating `stopped` as failure: manager gateway runs in daemon mode, so BoxLite status is not a reliable success signal for this skill. 8. Printing secrets in summaries or logs: always mask as `[REDACTED]` or `present`. 9. Calling CSGClaw SSE endpoint a Feishu webhook: it is an internal CSGClaw-to-PicoClaw bridge. 10. If Feishu changes the accounts registration endpoint or tenant policy blocks PersonalAgent creation, fall back to manual App ID/App Secret setup. @@ -342,7 +343,7 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag - [ ] `finalize` output shows `app_secret` only as `present`. - [ ] `finalize` configured `bot_id` and `app_id` in CSGClaw. - [ ] CSGClaw channel config was reloaded. -- [ ] CSGClaw bot exists with `channel=feishu`. +- [ ] CSGClaw participant exists with `channel=feishu`. - [ ] Existing worker agents are recreated after config reload. - [ ] New worker finalize was run with a tool timeout of at least 600 seconds. - [ ] Manager finalize returned a raw `csgclaw.action_card` JSON object with `rebuild-manager` action metadata for the web button. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py index c08d4609..0979b56b 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/commands.py @@ -229,7 +229,7 @@ def build_parser() -> argparse.ArgumentParser: finalize.add_argument("--registration-id", required=True) finalize.add_argument("--timeout", type=int, default=DEFAULT_EXPIRE_SECONDS) finalize.add_argument("--no-configure", action="store_true", help="Do not write CSGClaw config; for debugging only, still never prints secret") - finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/bots") + finalize.add_argument("--no-ensure-bot", action="store_true", help="Skip POST /api/v1/channels/feishu/participants") finalize.add_argument("--role", choices=["worker", "manager"], default="", help="Override role for ensure/recreate logic") finalize.add_argument("--bot-name", default="", help="Override bot name for ensure") finalize.add_argument("--description", default="", help="Override bot description for ensure") diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py index 1b9c1b8f..ccc811a5 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -99,22 +99,15 @@ def csgclaw_cli_json(args, cli_args: list[str], input_text: Optional[str] = None def configure_csgclaw(args, state: dict, result: dict) -> dict: bot_id = state["bot_id"] - cli_args = [ - "bot", - "config", - "--channel", - "feishu", - "--set", - "--bot-id", - bot_id, - "--app-id", - result["app_id"], - "--app-secret-stdin", - ] + payload = { + "bot_id": bot_id, + "app_id": result["app_id"], + "app_secret": result["app_secret"], + } candidate_admin_open_id = str(result.get("open_id") or "").strip() if bot_id == "u-manager" and candidate_admin_open_id: - cli_args.extend(["--admin-open-id", candidate_admin_open_id]) - response = csgclaw_cli_json(args, cli_args, input_text=result["app_secret"] + "\n") or {} + payload["admin_open_id"] = candidate_admin_open_id + response = api_json(args, "PUT", "/api/v1/channels/feishu/config", payload) or {} if bot_id == "u-manager": if candidate_admin_open_id: response["admin_open_id"] = candidate_admin_open_id @@ -140,12 +133,22 @@ def ensure_bot(args, state: dict, result: dict) -> Optional[dict]: description = args.description or state.get("description") or f"{name} Feishu {role} agent" payload = { "id": bot_id, + "type": "agent", "name": name, - "description": description, - "role": role, - "channel": "feishu", + "channel_app_ref": result.get("app_id") or state.get("app_id") or "", + "channel_user": {"ref": bot_id, "kind": "local_user_id"}, + "agent_binding": { + "mode": "create", + "agent_id": bot_id, + "agent": { + "id": bot_id, + "name": name, + "description": description, + "role": role, + }, + }, } - return api_json(args, "POST", f"/api/v1/channels/feishu/bots", payload) + return api_json(args, "POST", "/api/v1/channels/feishu/participants", payload) def worker_box_conflict_message(bot_id: str, name: str) -> str: @@ -162,10 +165,10 @@ def is_box_name_conflict(exc: RuntimeError, name: str) -> bool: def bot_exists(args, bot_id: str) -> bool: - bots = csgclaw_cli_json(args, ["bot", "list", "--channel", "feishu"]) - if not isinstance(bots, list): - raise RuntimeError(f"csgclaw-cli bot list returned unexpected JSON: {bots!r}") - return any(str(bot.get("id") or "").strip() == bot_id for bot in bots if isinstance(bot, dict)) + participants = csgclaw_cli_json(args, ["participant", "list", "--channel", "feishu", "--type", "agent"]) + if not isinstance(participants, list): + raise RuntimeError(f"csgclaw-cli participant list returned unexpected JSON: {participants!r}") + return any(str(item.get("id") or "").strip() == bot_id for item in participants if isinstance(item, dict)) def maybe_recreate(args, state: dict, worker_existed_before_ensure: Optional[bool] = None) -> Optional[dict]: diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md index c6d117b2..0145c794 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/SKILL.md @@ -23,22 +23,22 @@ Do not use this skill for: - collecting template `image_env` values - creating agents with `--from-template` -For those flows, use `agent-creator`. Never create a new worker with bare `bot create` from dispatch. +For those flows, use `agent-creator`. Never create a new worker with bare `participant create --bind create` from dispatch. ## Mandatory Dispatch Order When a user asks for manager-led coordination (especially GitLab planning, issue queries, breakdown, assignment, or execution handoff), follow this order and do not skip steps: 1. Read this skill first before any domain execution. -2. Check existing workers first (`bot list`) and prefer reusing a capable available worker. +2. Check existing workers first (`participant list`) and prefer reusing a capable available worker. 3. Ensure the selected worker is a member of the target room (add if missing). 4. If no suitable worker exists or the matching one is `unavailable`: - - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `bot create --from-template` + `--env`), then use `basics` to add the worker to the room. + - **New worker needed:** stop dispatch, follow `agent-creator` (hub list → hub get → `participant create --type agent --bind create --from-template` + `--env`), then use `basics` to add the worker to the room. - **Existing worker unavailable:** use `basics` / recreate paths for that bot id only; do not bare-create a replacement. 5. Dispatch the task to the worker in-room after membership is confirmed. Do not start with repo/code exploration, web fetch/search, or manager self-execution when the task is delegable to an existing or creatable worker. -Do not skip `bot list` by assuming worker availability from memory or prior turns. +Do not skip `participant list` by assuming worker availability from memory or prior turns. ## Fast Path @@ -60,7 +60,7 @@ Do not inspect or modify project implementation files before dispatch unless you ## Workflow 1. Break the admin request into concrete deliverables. -2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`bot list`) and reuse by matching `description`. +2. Match each task to the needed capability; use the `basics` skill to inspect existing workers first (`participant list`) and reuse by matching `description`. 3. If a suitable worker does not exist or is `unavailable`, provision via `agent-creator` (new) or recreate the existing bot (unavailable) before dispatch. 4. Use the `basics` skill to ensure every required worker has joined the target room, then verify the full required worker set. 5. Dispatch the user task to selected workers in-room after membership checks pass. @@ -167,7 +167,7 @@ Use the `basics` skill whenever this workflow needs any of these supporting oper - add a worker into the room - verify room membership before tracking -When a **new** worker is required, use `agent-creator` instead of bare `bot create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. +When a **new** worker is required, use `agent-creator` instead of bare `participant create --bind create`. Use `basics` here only for recreate when an existing listed worker is `unavailable`. ## Tracking Script Usage @@ -214,7 +214,7 @@ If you need to direct the human user to the project files on their Mac, point th Use this when exactly one worker should act (for example install a registry skill via `skill-installer`, run one GitLab job) and you do **not** need multi-step `todo.json` sequencing. -1. Use the `basics` skill: `bot list`, confirm room membership, then `message create --mention-id `. +1. Use the `basics` skill: `participant list`, confirm room membership, then `message create --mention-id `. 2. Do **not** reply in the room with plain `@worker-name` after registry skill discovery or other tools — that does not wake workers under `mention_only`. 3. Verify with `csgclaw-cli message list` that the dispatch message contains ``. 4. Use `start-tracking` only when multiple workers or ordered handoff is required. diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md index 88eec3e7..c9cfaa91 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/references/api-contract.md @@ -9,7 +9,7 @@ The skill and CLI use `room` as the user-facing term. Where the underlying HTTP - `CSGCLAW_BASE_URL`: Preferred when the script runs inside a CSGClaw box. - `CSGCLAW_ACCESS_TOKEN`: Preferred bearer token when the script runs inside a CSGClaw box. - `MANAGER_API_BASE_URL`: Optional. Default: `http://127.0.0.1:18080` -- `MANAGER_API_TOKEN`: Optional bearer token. Required for `/api/bots/*` when the server enables auth. +- `MANAGER_API_TOKEN`: Optional bearer token. Required for participant APIs when the server enables auth. - `MANAGER_API_TIMEOUT`: Optional request timeout in seconds. Default: `30` ## Local Config @@ -24,7 +24,7 @@ When available, load the CSGClaw API settings from `~/.picoclaw/config.json`: ### Dispatch task by bot message - Method: `POST` -- Path: `/api/bots/{bot_id}/messages/send` +- Path: `/api/v1/channels/csgclaw/participants/{participant_id}/messages` - Request body: ```json diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index cb76060f..b04055c0 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -513,7 +513,11 @@ def _mock_response( "payload": payload, } - if method == "POST" and path.startswith("/api/bots/") and path.endswith("/messages/send"): + if ( + method == "POST" + and path.startswith("/api/v1/channels/csgclaw/participants/") + and path.endswith("/messages") + ): result["message_id"] = "dry-run-message-id" return result diff --git a/web/app/src/api/agents.ts b/web/app/src/api/agents.ts index aaa0343d..e3f9e1da 100644 --- a/web/app/src/api/agents.ts +++ b/web/app/src/api/agents.ts @@ -36,6 +36,20 @@ export type AgentUpdatePayload = { from_template?: string; }; +export type ParticipantLike = { + agent_id?: string | null; + channel?: string | null; + channel_app_ref?: string | null; + channel_user_kind?: string | null; + channel_user_ref?: string | null; + id?: string | null; + lifecycle_status?: string | null; + mentionable?: boolean | null; + metadata?: JSONRecord | null; + name?: string | null; + type?: string | null; +}; + function modelPayload(draft: AgentProfileModelRequest): AgentProfileModelRequest { return { agent_id: draft.agent_id, @@ -56,12 +70,18 @@ export function saveManagerProfileRequest(profile: JSONRecord): Promise { void options; - const bots = await get("api/v1/channels/csgclaw/bots"); - return Array.isArray(bots) ? bots : []; + const [agents, notifications] = await Promise.all([ + get("api/v1/agents?include_participants=true"), + get("api/v1/channels/csgclaw/participants?type=notification"), + ]); + return [ + ...(Array.isArray(agents) ? agents : []), + ...(Array.isArray(notifications) ? notifications.map(participantToAgentLike) : []), + ]; } export function fetchAgent(agentID: string): Promise { - return get(`api/v1/agents/${encodeURIComponent(agentID)}`); + return get(`api/v1/agents/${encodeURIComponent(agentID)}?include_participants=true`); } export function fetchAgentLogsRequest(agentID: string, options: FetchAgentLogsOptions = {}): Promise { @@ -121,22 +141,63 @@ export type CreateBotPayload = AgentUpdatePayload & { type?: string; }; -export function createBotRequest(payload: CreateBotPayload): Promise { - return post("api/v1/channels/csgclaw/bots", payload); -} - -export function createNotificationBotRequest(payload: CreateBotPayload): Promise { - return post("api/v1/channels/csgclaw/bots", { ...payload, type: BOT_TYPE_NOTIFICATION }); +export async function createBotRequest(payload: CreateBotPayload): Promise { + const participant = await post("api/v1/channels/csgclaw/participants", { + name: payload.name, + type: "agent", + agent_binding: { + mode: "create", + agent: { + name: payload.name, + role: payload.role, + description: payload.description, + image: payload.image, + runtime_kind: payload.runtime_kind, + from_template: payload.from_template, + runtime_options: payload.runtime_options, + agent_profile: payload.agent_profile, + }, + }, + }); + return participant.agent_id ? fetchAgent(participant.agent_id) : participantToAgentLike(participant); +} + +export async function createNotificationBotRequest(payload: CreateBotPayload): Promise { + const participant = await post("api/v1/channels/csgclaw/participants", { + name: payload.name, + type: "notification", + metadata: payload.runtime_options ?? {}, + }); + return participantToAgentLike(participant); } export function patchNotificationBotRequest(botID: string, payload: CreateBotPayload): Promise { - return patch(`api/v1/channels/csgclaw/bots/${encodeURIComponent(botID)}`, payload); + return patch(`api/v1/channels/csgclaw/participants/${encodeURIComponent(botID)}`, { + name: payload.name, + metadata: payload.runtime_options ?? {}, + }).then(participantToAgentLike); } export function deleteBotRequest(botID: string): Promise { - return del(`api/v1/channels/csgclaw/bots/${encodeURIComponent(botID)}`); + return del(`api/v1/channels/csgclaw/participants/${encodeURIComponent(botID)}`); } export function runAgentActionRequest(agentID: string, action: string): Promise { return post(`api/v1/agents/${encodeURIComponent(agentID)}/${action}`); } + +function participantToAgentLike(participant: ParticipantLike): AgentLike { + const metadata = participant.metadata ?? {}; + return { + id: participant.id, + name: participant.name, + type: participant.type === "notification" ? BOT_TYPE_NOTIFICATION : participant.type, + bot_type: participant.type === "notification" ? BOT_TYPE_NOTIFICATION : participant.type, + available: participant.lifecycle_status === "active", + handle: participant.channel_user_ref, + runtime_options: metadata, + notification_profile: metadata, + notifier_profile: metadata, + status: participant.lifecycle_status, + }; +} diff --git a/web/app/src/api/im.ts b/web/app/src/api/im.ts index 947e6382..8bf7297c 100644 --- a/web/app/src/api/im.ts +++ b/web/app/src/api/im.ts @@ -77,5 +77,5 @@ export function joinAgentToRoomRequest(payload: JoinAgentToRoomPayload): Promise } export function createUserRequest(payload: CreateUserPayload): Promise { - return post("api/v1/users", payload); + return post("api/v1/channels/csgclaw/users", payload); } diff --git a/web/app/src/hooks/workspace/useAgentController.ts b/web/app/src/hooks/workspace/useAgentController.ts index 914a550f..b6a55c05 100644 --- a/web/app/src/hooks/workspace/useAgentController.ts +++ b/web/app/src/hooks/workspace/useAgentController.ts @@ -61,6 +61,7 @@ import { profileToDraft, providerNeedsAuth, resolvedNotifierWebhookOrigin, + resolveAgentChannelUserID, runtimeImageForKind, startAgentCreateProgress, } from "@/models/agents"; @@ -948,7 +949,7 @@ export function useAgentController({ try { let updatedAgent: AgentLike | null = null; if (action === "delete") { - await deleteBotRequest(item.id); + await deleteBotRequest(csgclawParticipantIDForAgent(item)); } else { updatedAgent = await runAgentActionRequest(item.id, action); } @@ -973,7 +974,7 @@ export function useAgentController({ setAgentActionBusy(`${item.id}:delete-bot`); setAgentsError(""); try { - await deleteBotRequest(item.id); + await deleteBotRequest(csgclawParticipantIDForAgent(item)); await refreshAgents(); await refreshWorkspaceBootstrap(); if (item.id === MANAGER_AGENT_ID) { @@ -1095,24 +1096,25 @@ export function useAgentController({ } async function openAgentDirectMessage(item: AgentLike | null | undefined): Promise { - if (!item?.id || !data?.current_user_id) { + const channelUserID = resolveAgentChannelUserID(item); + if (!channelUserID || !data?.current_user_id) { return; } setAgentsError(""); try { let nextData = null; - let direct = directConversationForUser(item.id); + let direct = directConversationForUser(channelUserID); if (!direct) { await createUserRequest({ - id: item.id, - name: item.name, - handle: item.handle || item.id.replace(/^u-/, "") || item.name, - role: item.role || WORKER_AGENT_ROLE, + id: channelUserID, + name: item?.name || channelUserID, + handle: item?.handle || channelUserID.replace(/^u-/, "") || item?.name, + role: item?.role || WORKER_AGENT_ROLE, }); nextData = await refreshWorkspaceBootstrap(); direct = directConversationForUser( - item.id, + channelUserID, nextData?.rooms ?? rooms, nextData?.current_user_id ?? data.current_user_id, ); @@ -1287,3 +1289,10 @@ export function useAgentController({ : null, }; } + +function csgclawParticipantIDForAgent(item: AgentLike): string { + const participant = item.participants?.find( + (candidate) => String(candidate?.channel || "").trim() === "csgclaw" && String(candidate?.id || "").trim(), + ); + return String(participant?.id || item.id || "").trim(); +} diff --git a/web/app/src/models/agents.ts b/web/app/src/models/agents.ts index b6ce3a9b..7ae11a62 100644 --- a/web/app/src/models/agents.ts +++ b/web/app/src/models/agents.ts @@ -11,6 +11,7 @@ import { DEFAULT_RUNTIME_KIND, MANAGER_AGENT_ID, MANAGER_AGENT_NAME, + MANAGER_PARTICIPANT_ID, MANAGER_AGENT_ROLE, RUNTIME_KIND_OPTIONS, WORKER_AGENT_ROLE, @@ -77,6 +78,19 @@ export type AgentLike = AgentProfileLike & { status?: string | null; template_name?: string | null; user_id?: string | null; + participants?: { + agent_id?: string | null; + channel?: string | null; + channel_app_ref?: string | null; + channel_user_kind?: string | null; + channel_user_ref?: string | null; + id?: string | null; + lifecycle_status?: string | null; + mentionable?: boolean | null; + metadata?: JSONRecord | null; + name?: string | null; + type?: string | null; + }[] | null; }; export type AvatarLikeUser = { @@ -188,6 +202,23 @@ export function isManagerAgent(item: AgentLike | null | undefined): boolean { return item?.role === MANAGER_AGENT_ROLE || item?.id === MANAGER_AGENT_ID; } +export function resolveAgentChannelUserID(item: AgentLike | null | undefined): string { + if (!item) { + return ""; + } + const participant = item.participants?.find( + (candidate) => String(candidate?.channel || "").trim() === "csgclaw" && String(candidate?.id || "").trim(), + ); + const channelUserID = String(participant?.channel_user_ref || participant?.id || "").trim(); + if (channelUserID) { + return channelUserID; + } + if (isManagerAgent(item)) { + return MANAGER_PARTICIPANT_ID; + } + return String(item.user_id || item.id || "").trim(); +} + export function normalizeNotifierDeliveryMode(mode: unknown): string { const value = String(mode || "") .trim() @@ -361,9 +392,9 @@ export function notificationBotMetaLabel(item: AgentLike | null | undefined, t: export function notificationPushWebhookPathForBot(botID: unknown): string { const id = String(botID || "").trim(); if (!id) { - return "/api/v1/channels/csgclaw/bots//notifications"; + return "/api/v1/channels/csgclaw/participants//notifications"; } - return `/api/v1/channels/csgclaw/bots/${encodeURIComponent(id)}/notifications`; + return `/api/v1/channels/csgclaw/participants/${encodeURIComponent(id)}/notifications`; } export function notifierPushWebhookPathForAgent(botID: unknown): string { @@ -714,7 +745,6 @@ export function agentToDraft(agent: AgentDraftSource | null | undefined): AgentD role: agent?.role || WORKER_AGENT_ROLE, bot_type: botType, description: agent?.description || profile.description || "", - avatar: agent?.avatar || "", default_image: agent?.image || "", image: agent?.image || "", from_template: agent?.from_template || "", diff --git a/web/app/src/models/composer.ts b/web/app/src/models/composer.ts index b22c4d62..0ab6ff12 100644 --- a/web/app/src/models/composer.ts +++ b/web/app/src/models/composer.ts @@ -18,6 +18,7 @@ export type ComposerSlashState = { query: string; startOffset: number; textNode: Node; + tokenElement?: HTMLElement; }; export function createMentionTokenElement(user) { @@ -401,12 +402,16 @@ export function getComposerSlashState(root) { } let context = getActiveTextQueryContext(range.startContainer, range.startOffset); if (!context && range.startContainer.nodeType === Node.ELEMENT_NODE) { - const textNode = getAdjacentSlashTokenTextNode(range.startContainer, range.startOffset); - if (textNode) { - context = { - textNode, - offset: textNode.textContent?.length ?? 0, - textBeforeCursor: textNode.textContent ?? "", + const tokenElement = getAdjacentSlashTokenElement(range.startContainer, range.startOffset); + if (tokenElement) { + const text = tokenElement.textContent ?? ""; + const query = text.startsWith("/") ? text.slice(1) : text; + return { + query, + startOffset: 0, + endOffset: text.length, + textNode: tokenElement.firstChild ?? tokenElement, + tokenElement, }; } } @@ -517,8 +522,16 @@ export function replaceComposerSlashWithSegments(root, segments) { } const range = document.createRange(); - range.setStart(slashState.textNode, slashState.startOffset); - range.setEnd(slashState.textNode, slashState.endOffset); + if (slashState.tokenElement) { + range.setStartBefore(slashState.tokenElement); + range.setEndAfter(slashState.tokenElement); + } else { + const endOffset = replacementEndsWithWhitespace(segments) + ? consumeSingleFollowingWhitespace(slashState.textNode, slashState.endOffset) + : slashState.endOffset; + range.setStart(slashState.textNode, slashState.startOffset); + range.setEnd(slashState.textNode, endOffset); + } range.deleteContents(); const marker = document.createTextNode(""); @@ -613,7 +626,7 @@ function isComposerTokenNode(node: Node | null): boolean { return Boolean(element.dataset?.userId || element.dataset?.composerSlashToken); } -function getAdjacentSlashTokenTextNode(node: Node, offset: number): Text | null { +function getAdjacentSlashTokenElement(node: Node, offset: number): HTMLElement | null { if (node.nodeType !== Node.ELEMENT_NODE) { return null; } @@ -622,11 +635,30 @@ function getAdjacentSlashTokenTextNode(node: Node, offset: number): Text | null return null; } const element = previous as HTMLElement; - if (!element.dataset?.composerSlashToken) { - return null; + return element.dataset?.composerSlashToken ? element : null; +} + +function replacementEndsWithWhitespace(segments): boolean { + for (let index = (segments?.length ?? 0) - 1; index >= 0; index -= 1) { + const segment = segments[index]; + if (!segment || segment.type === "mention") { + continue; + } + const text = String(segment.text ?? ""); + if (!text) { + continue; + } + return /\s$/.test(text); + } + return false; +} + +function consumeSingleFollowingWhitespace(textNode: Node, offset: number): number { + if (textNode.nodeType !== Node.TEXT_NODE) { + return offset; } - const textNode = element.firstChild; - return textNode?.nodeType === Node.TEXT_NODE ? (textNode as Text) : null; + const text = textNode.textContent ?? ""; + return /\s/.test(text.charAt(offset)) ? offset + 1 : offset; } export function placeCaretNearNode(root, node, direction) { diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 22fa5189..ca7ada09 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -70,7 +70,6 @@ export function AgentDetailPane({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, onOpenDM, @@ -140,7 +139,6 @@ export function AgentDetailPane({ publishBusy={publishBusy} onStart={onStart} onStop={onStop} - onUpgrade={onUpgrade} onRecreate={onRecreate} onInvite={onInvite} onDelete={onDelete} @@ -413,7 +411,6 @@ function AgentActionsMenu({ publishBusy, onStart, onStop, - onUpgrade, onRecreate, onInvite, onDelete, @@ -434,10 +431,6 @@ function AgentActionsMenu({ {running ? t("agentStop") : t("agentStart")} ) : null} - onUpgrade?.(item)}> - {t("agentUpgrade")} - {upgradeNeeded ? {t("agentUpdateAvailable")} : null} - onRecreate(item)}> {t("agentRecreate")} diff --git a/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx b/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx index 00dbe75d..447918ee 100644 --- a/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx +++ b/web/app/src/pages/AgentPage/components/AgentList/AgentList.tsx @@ -27,7 +27,6 @@ export function AgentSection({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, }) { @@ -59,7 +58,6 @@ export function AgentSection({ onStart={onStart} onStop={onStop} onRecreate={onRecreate} - onUpgrade={onUpgrade} onDelete={onDelete} onInvite={onInvite} /> @@ -82,7 +80,6 @@ export function AgentRow({ onStart, onStop, onRecreate, - onUpgrade, onDelete, onInvite, }) { @@ -140,14 +137,6 @@ export function AgentRow({ ) : null} {!isNotification ? ( <> -