From 1640b877b88fc1133b1d6307c8e7d6fdb8e7da44 Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Mon, 3 Aug 2026 18:59:58 +0800 Subject: [PATCH 1/2] fix(feishu): resolve canonical agent bindings --- internal/channel/feishu/service.go | 13 +++ internal/channel/feishu/service_test.go | 34 +++++- .../manager/codex/instructions/AGENTS.md | 1 + .../manager/codex/skills/feishu/SKILL.md | 49 +++++---- .../feishu/scripts/feishu_setup/commands.py | 70 ++++++++---- .../scripts/feishu_setup/registration.py | 13 +++ .../scripts/tests/test_manager_action_card.py | 104 ++++++++++++++++++ 7 files changed, 243 insertions(+), 41 deletions(-) diff --git a/internal/channel/feishu/service.go b/internal/channel/feishu/service.go index b48453de1..80ce865f7 100644 --- a/internal/channel/feishu/service.go +++ b/internal/channel/feishu/service.go @@ -23,6 +23,13 @@ import ( ) const ( + // The persisted Manager participant uses a typed ID (for example, + // "pt-manager"), so resolve its app through the canonical Agent ID instead + // of assuming a participant named "manager". + feishuManagerAgentID = "agent-manager" + + // feishuManagerBotID is retained for services constructed with an in-memory + // app map, whose historical Manager key is "manager". feishuManagerBotID = "manager" ) @@ -1943,6 +1950,12 @@ func (s *Service) participantMentionOpenIDLocked(participantID string) (string, } func (s *Service) managerAppConfigLocked() (AppConfig, error) { + if s.configProvider != nil { + participantID, app, ok := s.configProvider.BotConfigForAgent(feishuManagerAgentID) + if ok { + return validateAppConfig(app, participantID) + } + } app, ok := s.appConfigByIDLocked(feishuManagerBotID) if !ok { return AppConfig{}, fmt.Errorf("feishu app is not configured for %q", feishuManagerBotID) diff --git a/internal/channel/feishu/service_test.go b/internal/channel/feishu/service_test.go index 9792d4acd..0bedb9685 100644 --- a/internal/channel/feishu/service_test.go +++ b/internal/channel/feishu/service_test.go @@ -77,6 +77,7 @@ func testBotInfoResolver(t *testing.T, openIDsByAppID map[string]string) func(co type testFeishuConfigProvider struct { bots map[string]AppConfig + agentBots map[string]string mentionOpenIDs map[string]string adminOpenID string } @@ -86,8 +87,10 @@ func (p testFeishuConfigProvider) BotConfig(participantID string) (AppConfig, bo return app, ok } -func (p testFeishuConfigProvider) BotConfigForAgent(string) (string, AppConfig, bool) { - return "", AppConfig{}, false +func (p testFeishuConfigProvider) BotConfigForAgent(agentID string) (string, AppConfig, bool) { + participantID := strings.TrimSpace(p.agentBots[strings.TrimSpace(agentID)]) + app, ok := p.bots[participantID] + return participantID, app, participantID != "" && ok } func (p testFeishuConfigProvider) DefaultAdminOpenID() (string, bool) { @@ -335,6 +338,33 @@ func TestFeishuCreateRoomUsesConfiguredAdminOpenID(t *testing.T) { } } +func TestFeishuCreateRoomResolvesManagerAppByAgentID(t *testing.T) { + var gotApp AppConfig + svc := NewServiceWithProvider(testFeishuConfigProvider{ + bots: map[string]AppConfig{ + "pt-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + }, + agentBots: map[string]string{ + feishuManagerAgentID: "pt-manager", + }, + adminOpenID: "ou_admin", + }) + svc.createChat = func(_ context.Context, app AppConfig, req CreateChatRequest) (CreateChatResponse, error) { + gotApp = app + if got, want := req.CreatorID, "ou_admin"; got != want { + t.Fatalf("create chat creator_id = %q, want %q", got, want) + } + return CreateChatResponse{ChatID: "oc_agent_manager", Name: req.Title}, nil + } + + if _, err := svc.CreateRoom(im.CreateRoomRequest{Title: "alpha", CreatorID: "user-manager"}); err != nil { + t.Fatalf("CreateRoom() error = %v", err) + } + if got, want := gotApp.AppID, "cli_manager"; got != want { + t.Fatalf("create chat app_id = %q, want %q", got, want) + } +} + func TestFeishuCreateRoomRequiresConfiguredMemberBots(t *testing.T) { svc := NewServiceWithCreateChatAndAddMembers( map[string]AppConfig{"manager": {AppID: "cli_manager", AppSecret: "manager-secret", AdminOpenID: "ou_admin"}}, diff --git a/internal/template/embed/manager/codex/instructions/AGENTS.md b/internal/template/embed/manager/codex/instructions/AGENTS.md index baaf3057e..5bb73bef6 100644 --- a/internal/template/embed/manager/codex/instructions/AGENTS.md +++ b/internal/template/embed/manager/codex/instructions/AGENTS.md @@ -51,6 +51,7 @@ If the user wants to create, add, set up, or provision an agent, robot, bot, or This includes capability-specific workers such as GitLab, frontend, backend, QA, review, or Feishu-connected workers. Never run `participant create --type agent` for a new CSGClaw worker unless it binds a real Agent with `--bind create` or `--bind reuse`. Never run `participant create --bind create` without `--from-template` for a new worker. +For a request that only connects an already-named worker to Feishu, do not infer that the Agent is missing from `participant list`; read `skills/feishu/SKILL.md` first so its helper can resolve the global Agent registry by runtime ID or display name. ### Single-worker task assignment second diff --git a/internal/template/embed/manager/codex/skills/feishu/SKILL.md b/internal/template/embed/manager/codex/skills/feishu/SKILL.md index 1a5e977b7..54f0d9f26 100644 --- a/internal/template/embed/manager/codex/skills/feishu/SKILL.md +++ b/internal/template/embed/manager/codex/skills/feishu/SKILL.md @@ -12,7 +12,7 @@ This skill sets up Feishu/Lark bot app credentials for CSGClaw-managed manager a Use the bundled script from the Codex skill root: ```bash -python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start --agent u-dev --role worker --bot-name dev --qr +python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start --agent --role worker --bot-name dev --qr python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" finalize --registration-id ``` @@ -44,7 +44,7 @@ Do not add or require a public Feishu Open Platform HTTP webhook as the main inb Use this skill when the user asks to: -- create/configure Feishu credentials for the manager agent `u-manager` or a worker agent such as `u-dev` +- create/configure Feishu credentials for the manager agent `u-manager` or an existing worker Agent - generate a Feishu/Lark bot creation URL or QR code - get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw-managed agent - bind Feishu participant config after setting Feishu credentials @@ -55,11 +55,11 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei ## Terms -- Target agent ID: usually `u-manager`, `u-dev`, `u-qa`, etc. Pass it to the helper script with `--agent`. +- Target agent reference: use `u-manager` for the manager; for a worker, `start --agent` reads the Agent registry once and matches either its exact runtime Agent ID or exact display name. - Feishu `app_id` / `app_secret`: the Feishu bot application's credentials. - AK/SK in user wording usually means Feishu `app_id/app_secret` or `client_id/client_secret` returned by the registration flow. - Manager agent: usually `u-manager`; recreating it can interrupt the current manager skill run. -- Worker agent: any non-manager agent, for example `u-dev`; recreating it is usually safe after config succeeds. +- Worker agent: any non-manager Agent; recreating it is usually safe after config succeeds. ## Prerequisites @@ -105,6 +105,12 @@ For Feishu group operations, `room create --member-ids`, `csgclaw-cli member lis 4. Do not store returned `client_secret` in skill state files. `finalize` pipes it directly to `csgclaw-cli participant bind --app-secret-stdin`. 5. Verify with `csgclaw-cli participant list --channel feishu` and check the `channel_app_config.app_id` you configured; keep `app_secret` masked. +## User-Facing Completion Reply + +Treat the script's JSON as tool output, not as the chat reply. After a successful `finalize` or `bind-manager`, reply in the user's language with one concise confirmation, for example: `已完成 manager 的飞书对接,飞书桥接已生效。` + +Include the target Agent name and, when useful, one safe next step such as “请在飞书中发送一条消息测试”。 Never return or quote the raw JSON object, its fields, or any secret-related status in the chat reply. If the operation fails or is partial, summarize the failure and the next action in plain language instead. + ## Choose Target Agent Ask for the target when it is not explicit. @@ -116,19 +122,22 @@ If the user asks to **create/provision/add a new worker and connect it to Feishu Do not run Feishu `start`, `finalize`, or `participant bind --feishu-kind bot` for a worker that does not exist yet. `participant bind` only attaches Feishu credentials to an existing agent; it does not create the worker. -If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager` 或 `dev`/`u-dev`)". +If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager`、worker 显示名称,或 `agent-...` ID)". Resolve target: 1. If input is `manager` or `u-manager`, treat as manager flow. -2. Otherwise, treat input as worker flow, set the target agent ID to the input if it already starts with `u-`, otherwise prefix `u-`. -3. If only role was inferred as manager, stop using recreate path and force the manager binding activation flow. - -Example normalization: -- `dev` -> worker agent `u-dev`, participant `dev` -- `u-dev` -> worker agent `u-dev`, participant `dev` +2. Otherwise, treat input as a worker **Agent reference**. Pass it unchanged to `start --agent`; it calls `GET /api/v1/agents` once, then matches an exact runtime Agent ID or display name in that registry. +3. Use the canonical `agent_id` returned by `start` for the rest of the flow. Never manufacture an ID by adding `u-` to a name. +4. `participant list --channel csgclaw` and `participant list --channel feishu` only list channel participants. Their absence does **not** prove that a worker Agent is missing, so never invoke `agent-creator` solely because those lists do not contain the worker. +5. Invoke `agent-creator` only when `start` reports that the Agent was not found. If the input matches multiple Agents (including an ID/name collision), ask for the runtime Agent ID instead. +6. If only role was inferred as manager, stop using recreate path and force the manager binding activation flow. + +Examples: +- `dev` -> resolve the existing Agent whose display name is `dev`, then use its returned runtime Agent ID +- `agent-dev` -> use that existing runtime Agent ID directly - `manager` -> manager - `u-manager` -> manager -For worker flow, `finalize` calls `csgclaw-cli participant bind --feishu-kind bot`. The bind command saves the Feishu participant config and recreates the worker unless the skill helper was run with `finalize --recreate none` or `finalize --recreate manager`. +For worker flow, `finalize` calls `csgclaw-cli participant bind --feishu-kind bot` with the resolved runtime Agent ID. The bind command saves the Feishu participant config and recreates the worker unless the skill helper was run with `finalize --recreate none` or `finalize --recreate manager`. If the target worker is missing, `start` fails before creating a Feishu app and points back to `agent-creator`. ## Primary QR/Launcher Flow @@ -139,7 +148,7 @@ Run from this skill directory: ```bash python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start \ - --agent \ + --agent \ --role worker \ --bot-name \ --description "dev worker agent" \ @@ -187,7 +196,7 @@ python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" finalize --registr Use an exec/tool timeout of at least 600 seconds for this command. The bind command should report `restart_status`; do not create a second worker or change the target agent ID. Worker finalize must not bind or overwrite `feishu:admin`, even when Feishu returns a registration `open_id`; `feishu:admin` belongs to the manager Feishu app scope. -For manager, default finalize binds `feishu:admin` when Feishu returns `open_id`, binds `feishu:manager`, calls the binding activation API, then prints a structured JSON object. 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. A successful manager finalize includes `config.binding_activation` / `activation` and no `rebuild-manager` action. +For manager, default finalize binds `feishu:admin` when Feishu returns `open_id`, binds `feishu:manager`, calls the binding activation API, then prints a structured JSON object for the tool. Follow [User-Facing Completion Reply](#user-facing-completion-reply) instead of returning that object. A successful manager finalize includes `config.binding_activation` / `activation` and no `rebuild-manager` action. Do not run `python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" recreate-agent --agent u-manager` as a terminal self-recreate step. A normal manager finalize should not produce or require a manager rebuild action. @@ -227,7 +236,7 @@ Use `participant bind` to set manually: printf '%s' '[REDACTED]' | csgclaw-cli participant bind \ --channel feishu \ --feishu-kind bot \ - --agent u-dev \ + --agent \ --app-id cli_xxx \ --app-secret-stdin \ --restart @@ -242,7 +251,7 @@ printf '%s' '[REDACTED]' | python "$CODEX_HOME/skills/feishu/scripts/feishu_regi --app-secret-stdin ``` -Return the printed JSON object exactly as the chat response. Do not summarize it, translate it, add a Markdown table, or wrap it in a code fence. +After a successful manual binding, follow [User-Facing Completion Reply](#user-facing-completion-reply). Do not echo the printed JSON. ## CLI Workflow Used by Script @@ -278,7 +287,7 @@ Expected wrapper response shape: For workers, the bind command recreates the worker by default so the runtime picks up the updated Feishu credentials: ```bash -printf '%s' '[REDACTED]' | csgclaw-cli participant bind --channel feishu --feishu-kind bot --agent u-dev --app-id cli_xxx --app-secret-stdin --restart +printf '%s' '[REDACTED]' | csgclaw-cli participant bind --channel feishu --feishu-kind bot --agent --app-id cli_xxx --app-secret-stdin --restart ``` ## CLI Workflow for Manual Control @@ -286,7 +295,7 @@ printf '%s' '[REDACTED]' | csgclaw-cli participant bind --channel feishu --feish Use `participant bind` for channel config. The manager wrapper automatically activates its Feishu bridge without recreating the manager runtime. ```bash -printf '%s' '[REDACTED]' | csgclaw-cli participant bind --channel feishu --feishu-kind bot --agent u-dev --app-id cli_xxx --app-secret-stdin --restart +printf '%s' '[REDACTED]' | csgclaw-cli participant bind --channel feishu --feishu-kind bot --agent --app-id cli_xxx --app-secret-stdin --restart ``` ## Worker One-Shot Recipe @@ -326,7 +335,7 @@ python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start --agent u-ma python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" finalize --registration-id ``` -4. Return the `finalize` JSON object exactly as the chat response. Do not summarize it, translate it, add a Markdown table, or wrap it in a code fence. The object reports the completed binding activation. +4. Follow [User-Facing Completion Reply](#user-facing-completion-reply). Do not echo the `finalize` JSON object. 5. Do not call a manager recreate API or host command from this skill. The script activates the updated Feishu binding through CSGClaw without rebuilding the Manager. @@ -337,7 +346,7 @@ 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 commands from inside manager: manager usually only has `csgclaw-cli`; use this script/API from manager, and ask the host operator to clean stale runtime state if needed. 3. If you see older workflow docs mentioning alternate Feishu config commands, ignore them and use `csgclaw-cli participant bind ...` to write config. -4. Binding the wrong target: pass the CSGClaw agent ID such as `u-dev` or `u-manager`; the bind command writes the canonical Feishu participant ID. +4. Binding the wrong target: for a worker pass the resolved runtime Agent ID (or a unique display name); never derive an ID by prepending `u-`. The bind command writes the canonical Feishu participant ID. 5. Expecting bind alone to update an already-running worker: worker recreate is still required; the manager wrapper activates its binding automatically. 6. Calling manager recreate from inside this manager-hosted skill: use the binding activation result instead; it preserves the current Codex session. 7. Treating a binding activation API failure as configured: the participant may be saved, but the activation must succeed before the Skill reports completion. diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py index 41d680727..c4c4c1816 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py @@ -26,6 +26,7 @@ poll_until_success, render_ascii_qr, validate_agent_id, + validate_agent_reference, ) from .state import delete_state, load_state, save_state, state_path @@ -100,20 +101,48 @@ def manager_secret_cli_args(args: argparse.Namespace) -> tuple[list[str], Option return ["--app-secret-stdin"], sys.stdin.read() -def ensure_worker_agent_exists(args: argparse.Namespace, agent_id: str, role: str) -> None: - if role != "worker": - return - try: - api_json(args, "GET", f"/api/v1/agents/{path_id(agent_id)}", None) - except RuntimeError as exc: - if "HTTP 404" not in str(exc): - raise +def worker_agent_identity(agent_ref: str, item: Any) -> tuple[str, str]: + if not isinstance(item, dict): + raise RuntimeError(f"CSGClaw returned an invalid agent record while resolving {agent_ref!r}") + agent_id = validate_agent_id(str(item.get("id") or "")) + role = str(item.get("role") or "").strip().lower() + if role and role != "worker": + raise RuntimeError( + f"target agent {agent_ref!r} resolved to {agent_id!r}, whose role is {role!r}; " + "use the manager Feishu flow for a manager agent" + ) + return agent_id, str(item.get("name") or "").strip() + + +def resolve_worker_agent_reference(args: argparse.Namespace, agent_ref: str) -> tuple[str, str]: + """Resolve an existing worker from the Agent registry without ID alias ambiguity.""" + agents = api_json(args, "GET", "/api/v1/agents", None) + if not isinstance(agents, list): + raise RuntimeError("CSGClaw returned an invalid agent list while resolving the worker") + id_matches = [ + item + for item in agents + if isinstance(item, dict) and str(item.get("id") or "").strip() == agent_ref + ] + name_matches = [ + item + for item in agents + if isinstance(item, dict) and str(item.get("name") or "").strip().casefold() == agent_ref.casefold() + ] + matches = {str(item.get("id") or "").strip(): item for item in [*id_matches, *name_matches]} + if len(matches) == 1: + return worker_agent_identity(agent_ref, next(iter(matches.values()))) + if len(matches) > 1: raise RuntimeError( - f"target worker agent {agent_id!r} does not exist yet. " - "For a request like 'create dev worker and connect Feishu', run the agent-creator skill first " - "to create the worker with `csgclaw-cli participant create --type agent --bind create --from-template ...`, " - "then return to the Feishu skill and start registration for this existing agent." - ) from None + f"agent reference {agent_ref!r} matched multiple agents; " + "specify the runtime Agent ID shown by GET /api/v1/agents" + ) + raise RuntimeError( + f"target worker agent {agent_ref!r} was not found by exact runtime Agent ID or display name. " + "For a request like 'create dev worker and connect Feishu', run the agent-creator skill first " + "to create the worker with `csgclaw-cli participant create --type agent --bind create --from-template ...`, " + "then return to the Feishu skill and start registration for this existing agent." + ) def cmd_bind_manager(args: argparse.Namespace) -> int: @@ -181,10 +210,13 @@ def cmd_bind_manager(args: argparse.Namespace) -> int: def cmd_start(args: argparse.Namespace) -> int: - agent_id = validate_agent_id(args.agent) + agent_ref = validate_agent_reference(args.agent) domain = args.domain - role = args.role or ("manager" if agent_id == "u-manager" else "worker") - ensure_worker_agent_exists(args, agent_id, role) + role = args.role or ("manager" if agent_ref == "u-manager" else "worker") + agent_id = agent_ref + agent_name = "" + if role == "worker": + agent_id, agent_name = resolve_worker_agent_reference(args, agent_ref) init_registration(domain) begin = begin_registration(domain) registration_id = str(uuid.uuid4()) @@ -193,7 +225,7 @@ def cmd_start(args: argparse.Namespace) -> int: "registration_id": registration_id, "agent_id": agent_id, "role": role, - "bot_name": args.bot_name or agent_id.removeprefix("u-") or agent_id, + "bot_name": args.bot_name or agent_name or agent_id.removeprefix("u-") or agent_id, "description": args.description or "", "domain": domain, "device_code": begin["device_code"], @@ -338,8 +370,8 @@ def build_parser() -> argparse.ArgumentParser: start = sub.add_parser("start", help="Start QR registration and print URL/QR") add_common(start) add_api_common(start) - start.add_argument("--agent", required=True, help="CSGClaw agent id, e.g. u-dev or u-manager") - start.add_argument("--role", choices=["worker", "manager"], default="", help="Agent role; inferred from agent id when omitted") + start.add_argument("--agent", required=True, help="CSGClaw runtime Agent ID or exact display name, e.g. agent-dev or dev") + start.add_argument("--role", choices=["worker", "manager"], default="", help="Agent role; inferred from u-manager when omitted") start.add_argument("--bot-name", default="", help="CSGClaw bot display name") start.add_argument("--description", default="", help="CSGClaw bot description") start.add_argument("--domain", choices=["feishu", "lark"], default="feishu") diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/registration.py b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/registration.py index 72902bf4c..20783d7ee 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/registration.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/registration.py @@ -31,6 +31,19 @@ def validate_agent_id(agent_id: str) -> str: return agent_id +def validate_agent_reference(agent_ref: str) -> str: + """Validate an Agent ID or display-name reference accepted by `start`. + + Agent IDs have a restricted syntax, but display names may be Unicode. The + caller resolves a display name against the CSGClaw Agent registry before it + is used as an API path segment or persisted as an Agent ID. + """ + agent_ref = (agent_ref or "").strip() + if not agent_ref: + raise RuntimeError("--agent is required") + return agent_ref + + def append_launcher_params(url: str, source: str = "csgclaw") -> str: parsed = urlparse(url) query = dict(parse_qsl(parsed.query, keep_blank_values=True)) diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py b/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py index 92e6f6b13..5bbcc5113 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py @@ -82,6 +82,110 @@ def fake_api_json(args, method, path, body=None): self.assertEqual(api_calls, [("POST", "/api/v1/agents/u-manager/bindings:apply?channel=feishu", None)]) self.assertTrue(any("--app-secret-env" in call[0] for call in calls)) + def test_start_resolves_existing_worker_by_display_name_with_one_registry_request(self): + api_calls = [] + saved_states = [] + originals = { + "api_json": commands.api_json, + "init_registration": commands.init_registration, + "begin_registration": commands.begin_registration, + "save_state": commands.save_state, + "state_path": commands.state_path, + } + + def fake_api_json(args, method, path, body=None): + api_calls.append((method, path, body)) + if path == "/api/v1/agents": + return [ + {"id": "agent-meds2g", "name": "generic-assistant-codex", "role": "worker"}, + {"id": "agent-otep2a", "name": "generic-assistant-codex-2", "role": "worker"}, + ] + self.fail(f"unexpected API request: {method} {path}") + + commands.api_json = fake_api_json + commands.init_registration = lambda domain: None + commands.begin_registration = lambda domain: { + "device_code": "device-code", + "qr_url": "https://example.test/qr", + "interval": 5, + "expire_in": 600, + } + commands.save_state = lambda args, state: saved_states.append(state.copy()) + commands.state_path = lambda args, registration_id: Path("/tmp") / f"{registration_id}.json" + try: + args = Namespace( + agent="generic-assistant-codex", + domain="feishu", + role="worker", + bot_name="", + description="", + timeout=600, + json=True, + qr=False, + state_dir="", + ) + stdout = StringIO() + with redirect_stdout(stdout): + exit_code = commands.cmd_start(args) + finally: + for name, value in originals.items(): + setattr(commands, name, value) + + self.assertEqual(exit_code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["agent_id"], "agent-meds2g") + self.assertEqual(saved_states[0]["agent_id"], "agent-meds2g") + self.assertEqual(saved_states[0]["bot_name"], "generic-assistant-codex") + self.assertEqual( + api_calls, + [ + ("GET", "/api/v1/agents", None), + ], + ) + + def test_resolve_worker_reference_matches_exact_runtime_id(self): + api_calls = [] + original_api_json = commands.api_json + + def fake_api_json(args, method, path, body=None): + api_calls.append((method, path, body)) + self.assertEqual(path, "/api/v1/agents") + return [{"id": "agent-meds2g", "name": "generic-assistant-codex", "role": "worker"}] + + commands.api_json = fake_api_json + try: + resolved = commands.resolve_worker_agent_reference(Namespace(), "agent-meds2g") + finally: + commands.api_json = original_api_json + + self.assertEqual(resolved, ("agent-meds2g", "generic-assistant-codex")) + self.assertEqual(api_calls, [("GET", "/api/v1/agents", None)]) + + def test_start_rejects_ambiguous_worker_reference_before_registration(self): + initialization = [] + original_api_json = commands.api_json + original_init_registration = commands.init_registration + + def fake_api_json(args, method, path, body=None): + if path == "/api/v1/agents": + return [ + {"id": "agent-dev", "name": "backend", "role": "worker"}, + {"id": "agent-other", "name": "agent-dev", "role": "worker"}, + ] + self.fail(f"unexpected API request: {method} {path}") + + commands.api_json = fake_api_json + commands.init_registration = lambda domain: initialization.append(domain) + try: + args = Namespace(agent="agent-dev", domain="feishu", role="worker") + with self.assertRaisesRegex(RuntimeError, "reference .* matched multiple agents"): + commands.cmd_start(args) + finally: + commands.api_json = original_api_json + commands.init_registration = original_init_registration + + self.assertEqual(initialization, []) + def test_configure_worker_skips_admin_from_registration_open_id(self): calls = [] original_csgclaw_cli_json = csgclaw.csgclaw_cli_json From 61abec40680b2c9d3530c0ab4b3c792929495fdb Mon Sep 17 00:00:00 2001 From: GatewayJ <835269233@qq.com> Date: Mon, 3 Aug 2026 20:11:23 +0800 Subject: [PATCH 2/2] fix(feishu): handle manager group setup --- internal/channel/feishu/service.go | 24 ++++++++++ internal/channel/feishu/service_test.go | 24 +++++++++- .../manager/codex/skills/feishu/SKILL.md | 25 +++++----- .../feishu/scripts/feishu_setup/commands.py | 23 ++++----- .../feishu/scripts/feishu_setup/csgclaw.py | 7 ++- .../scripts/tests/test_manager_action_card.py | 48 +++++++++++++++++++ 6 files changed, 125 insertions(+), 26 deletions(-) diff --git a/internal/channel/feishu/service.go b/internal/channel/feishu/service.go index 80ce865f7..56f9725b7 100644 --- a/internal/channel/feishu/service.go +++ b/internal/channel/feishu/service.go @@ -538,6 +538,7 @@ func (s *Service) CreateRoom(req im.CreateRoomRequest) (im.Room, error) { if err != nil { return im.Room{}, err } + memberBotIDs, memberAppIDs = excludeCallingBot(memberBotIDs, memberAppIDs, app.AppID) description := strings.TrimSpace(req.Description) created, err := s.createChat(context.Background(), app, CreateChatRequest{ @@ -2044,6 +2045,29 @@ func (s *Service) appIDsForMembers(memberIDs []string) ([]string, error) { return appIDs, nil } +// excludeCallingBot keeps the CSGClaw membership record intact while avoiding +// a duplicate Feishu invitation for the bot app that created the chat. Feishu +// adds that app's bot to the chat automatically. +func excludeCallingBot(memberBotIDs, memberAppIDs []string, callingAppID string) ([]string, []string) { + callingAppID = strings.TrimSpace(callingAppID) + if callingAppID == "" || len(memberAppIDs) == 0 { + return memberBotIDs, memberAppIDs + } + + filteredBotIDs := make([]string, 0, len(memberBotIDs)) + filteredAppIDs := make([]string, 0, len(memberAppIDs)) + for index, appID := range memberAppIDs { + if strings.EqualFold(strings.TrimSpace(appID), callingAppID) { + continue + } + filteredAppIDs = append(filteredAppIDs, appID) + if index < len(memberBotIDs) { + filteredBotIDs = append(filteredBotIDs, memberBotIDs[index]) + } + } + return filteredBotIDs, filteredAppIDs +} + func normalizeNonEmptyStrings(values []string) []string { normalized := make([]string, 0, len(values)) for _, value := range values { diff --git a/internal/channel/feishu/service_test.go b/internal/channel/feishu/service_test.go index 0bedb9685..cb41c2d69 100644 --- a/internal/channel/feishu/service_test.go +++ b/internal/channel/feishu/service_test.go @@ -317,7 +317,8 @@ func TestFeishuCreateRoomUsesConfiguredAdminOpenID(t *testing.T) { }, ) - if _, err := svc.CreateRoom(im.CreateRoomRequest{Title: "alpha", CreatorID: "u-manager", MemberIDs: []string{"u-dev"}}); err != nil { + room, err := svc.CreateRoom(im.CreateRoomRequest{Title: "alpha", CreatorID: "admin", MemberIDs: []string{"manager", "u-dev"}}) + if err != nil { t.Fatalf("CreateRoom() error = %v", err) } @@ -336,13 +337,18 @@ func TestFeishuCreateRoomUsesConfiguredAdminOpenID(t *testing.T) { if len(gotAddReq.MemberAppIDs) != 1 || gotAddReq.MemberAppIDs[0] != "cli_dev" { t.Fatalf("add members app_ids = %+v, want [cli_dev]", gotAddReq.MemberAppIDs) } + if got, want := strings.Join(room.Members, ","), "admin,manager,u-dev"; got != want { + t.Fatalf("room members = %+v, want manager recorded without invitation", room.Members) + } } func TestFeishuCreateRoomResolvesManagerAppByAgentID(t *testing.T) { var gotApp AppConfig + var gotAddReq AddChatMembersRequest svc := NewServiceWithProvider(testFeishuConfigProvider{ bots: map[string]AppConfig{ "pt-manager": {AppID: "cli_manager", AppSecret: "manager-secret"}, + "pt-worker": {AppID: "cli_worker", AppSecret: "worker-secret"}, }, agentBots: map[string]string{ feishuManagerAgentID: "pt-manager", @@ -356,13 +362,27 @@ func TestFeishuCreateRoomResolvesManagerAppByAgentID(t *testing.T) { } return CreateChatResponse{ChatID: "oc_agent_manager", Name: req.Title}, nil } + svc.addChatMembers = func(_ context.Context, _ AppConfig, req AddChatMembersRequest) error { + gotAddReq = req + return nil + } - if _, err := svc.CreateRoom(im.CreateRoomRequest{Title: "alpha", CreatorID: "user-manager"}); err != nil { + if _, err := svc.CreateRoom(im.CreateRoomRequest{ + Title: "alpha", + CreatorID: "user-manager", + MemberIDs: []string{"pt-manager", "pt-worker"}, + }); err != nil { t.Fatalf("CreateRoom() error = %v", err) } if got, want := gotApp.AppID, "cli_manager"; got != want { t.Fatalf("create chat app_id = %q, want %q", got, want) } + if got, want := strings.Join(gotAddReq.MemberBotIDs, ","), "pt-worker"; got != want { + t.Fatalf("add member bot ids = %+v, want %q", gotAddReq.MemberBotIDs, want) + } + if got, want := strings.Join(gotAddReq.MemberAppIDs, ","), "cli_worker"; got != want { + t.Fatalf("add member app ids = %+v, want %q", gotAddReq.MemberAppIDs, want) + } } func TestFeishuCreateRoomRequiresConfiguredMemberBots(t *testing.T) { diff --git a/internal/template/embed/manager/codex/skills/feishu/SKILL.md b/internal/template/embed/manager/codex/skills/feishu/SKILL.md index 54f0d9f26..1ce813aca 100644 --- a/internal/template/embed/manager/codex/skills/feishu/SKILL.md +++ b/internal/template/embed/manager/codex/skills/feishu/SKILL.md @@ -44,7 +44,7 @@ Do not add or require a public Feishu Open Platform HTTP webhook as the main inb Use this skill when the user asks to: -- create/configure Feishu credentials for the manager agent `u-manager` or an existing worker Agent +- create/configure Feishu credentials for the manager agent (`manager`, `u-manager`, or `agent-manager`) or an existing worker Agent - generate a Feishu/Lark bot creation URL or QR code - get Feishu AK/SK, App ID/App Secret, or client_id/client_secret for a CSGClaw-managed agent - bind Feishu participant config after setting Feishu credentials @@ -55,10 +55,10 @@ Do not use this skill for generic Feishu webhook integrations or non-CSGClaw Fei ## Terms -- Target agent reference: use `u-manager` for the manager; for a worker, `start --agent` reads the Agent registry once and matches either its exact runtime Agent ID or exact display name. +- Target agent reference: use `manager`, `u-manager`, or `agent-manager` for the manager; for a worker, `start --agent` reads the Agent registry once and matches either its exact runtime Agent ID or exact display name. - Feishu `app_id` / `app_secret`: the Feishu bot application's credentials. - AK/SK in user wording usually means Feishu `app_id/app_secret` or `client_id/client_secret` returned by the registration flow. -- Manager agent: usually `u-manager`; recreating it can interrupt the current manager skill run. +- Manager agent: `manager`, `u-manager`, and `agent-manager` are equivalent references; recreating it can interrupt the current manager skill run. - Worker agent: any non-manager Agent; recreating it is usually safe after config succeeds. ## Prerequisites @@ -82,11 +82,11 @@ CSGClaw cannot silently grant Feishu/Lark app scopes from inside the Manager run For new Feishu groups, after the manager and worker Feishu configs exist, prefer creating the group with all participant IDs already included: ```bash -csgclaw-cli room create --title worker-group --creator-id admin --member-ids manager, --channel feishu +csgclaw-cli room create --title worker-group --creator-id admin --member-ids pt-manager, --channel feishu ``` -CSGClaw creates the Feishu chat first, then resolves those participant IDs to configured Feishu app credentials and invites the worker bot apps. This keeps the created `chat_id` visible if the invite fails, but it still requires manager app group scopes for chat creation and member invites. -When creating the group from a direct/private request, keep the human requester as `--creator-id` (default `admin`) so the requester is recorded in the CSGClaw room members. Include `manager` plus the requested worker participant IDs in `--member-ids`, and replace `` with IDs from `participant list`. +CSGClaw records the Manager participant in the room, but does not invite its Feishu app again: the Manager app bot is automatically added when it creates the chat. It resolves and invites only the worker bot apps. This keeps the created `chat_id` visible if a worker invite fails, but it still requires manager app group scopes for chat creation and member invites. +When creating the group from a direct/private request, keep the human requester as `--creator-id` (default `admin`) so the requester is recorded in the CSGClaw room members. Include the actual Manager participant ID (normally `pt-manager`) plus the requested worker participant IDs in `--member-ids`; obtain them from `participant list`. For Feishu group operations, `room create --member-ids`, `csgclaw-cli member list`, and `member create` require manager app scopes such as: @@ -122,9 +122,9 @@ If the user asks to **create/provision/add a new worker and connect it to Feishu Do not run Feishu `start`, `finalize`, or `participant bind --feishu-kind bot` for a worker that does not exist yet. `participant bind` only attaches Feishu credentials to an existing agent; it does not create the worker. -If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager`、worker 显示名称,或 `agent-...` ID)". +If the user does not specify an agent in the request, ask: "请明确要对接飞书的目标 Agent 名字(如 `manager`/`u-manager`/`agent-manager`、worker 显示名称,或 `agent-...` ID)". Resolve target: -1. If input is `manager` or `u-manager`, treat as manager flow. +1. If input is `manager`, `u-manager`, or `agent-manager`, treat as manager flow. 2. Otherwise, treat input as a worker **Agent reference**. Pass it unchanged to `start --agent`; it calls `GET /api/v1/agents` once, then matches an exact runtime Agent ID or display name in that registry. 3. Use the canonical `agent_id` returned by `start` for the rest of the flow. Never manufacture an ID by adding `u-` to a name. 4. `participant list --channel csgclaw` and `participant list --channel feishu` only list channel participants. Their absence does **not** prove that a worker Agent is missing, so never invoke `agent-creator` solely because those lists do not contain the worker. @@ -136,6 +136,7 @@ Examples: - `agent-dev` -> use that existing runtime Agent ID directly - `manager` -> manager - `u-manager` -> manager +- `agent-manager` -> manager For worker flow, `finalize` calls `csgclaw-cli participant bind --feishu-kind bot` with the resolved runtime Agent ID. The bind command saves the Feishu participant config and recreates the worker unless the skill helper was run with `finalize --recreate none` or `finalize --recreate manager`. If the target worker is missing, `start` fails before creating a Feishu app and points back to `agent-creator`. @@ -198,7 +199,7 @@ Worker finalize must not bind or overwrite `feishu:admin`, even when Feishu retu For manager, default finalize binds `feishu:admin` when Feishu returns `open_id`, binds `feishu:manager`, calls the binding activation API, then prints a structured JSON object for the tool. Follow [User-Facing Completion Reply](#user-facing-completion-reply) instead of returning that object. A successful manager finalize includes `config.binding_activation` / `activation` and no `rebuild-manager` action. -Do not run `python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" recreate-agent --agent u-manager` as a terminal self-recreate step. A normal manager finalize should not produce or require a manager rebuild action. +Do not run `python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" recreate-agent` for the Manager as a terminal self-recreate step. A normal manager finalize should not produce or require a manager rebuild action. For manager only, do not use host runtime status as a post-recreate success check in this skill. The manager path is complete when the binding activation result succeeds. @@ -257,7 +258,7 @@ After a successful manual binding, follow [User-Facing Completion Reply](#user-f The script writes Feishu config through `csgclaw-cli participant bind` because skills should not edit host files directly. -For `u-manager`, `bind-manager` binds `feishu:admin` when `--open-id` is provided, binds `feishu:manager` without restarting the Codex runtime, then calls the Agent binding activation API to refresh the Feishu bridge against the existing Codex session: +For any Manager reference (`manager`, `u-manager`, or `agent-manager`), `bind-manager` binds `feishu:admin` when `--open-id` is provided, binds `feishu:manager` without restarting the Codex runtime, then calls the Agent binding activation API to refresh the Feishu bridge against the existing Codex session: ```bash printf '%s' '[REDACTED]' | python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" bind-manager --open-id ou_xxx --app-id cli_xxx --app-secret-stdin @@ -325,7 +326,7 @@ Run this recipe from the normal flow and use the returned binding activation res 1. Start registration: ```bash -python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start --agent u-manager --role manager --bot-name manager --description "manager agent" --qr +python "$CODEX_HOME/skills/feishu/scripts/feishu_register.py" start --agent agent-manager --role manager --bot-name manager --description "manager agent" --qr ``` 2. Send the printed URL/QR to the user. @@ -359,7 +360,7 @@ Do not use the generic manager recreate endpoint or any terminal/host-side manag - [ ] `start` printed a launcher URL or QR code for the user. - [ ] `finalize` output shows `app_secret` only as `present`. - [ ] `finalize` configured the target agent ID (`agent_id` field) and `app_id` in CSGClaw. -- [ ] `config.bot_bind.participant_id` is the canonical Feishu participant ID, such as `dev` or `manager`. +- [ ] `config.bot_bind.participant_id` is the canonical Feishu participant ID, such as `pt-dev` or `pt-manager`. - [ ] CSGClaw participant exists with `channel=feishu`. - [ ] Worker bind reported `restart_status` such as `worker_recreated` or `restart_skipped`. - [ ] New worker finalize was run with a tool timeout of at least 600 seconds. diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py index c4c4c1816..4f07a2198 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/commands.py @@ -15,6 +15,7 @@ api_json, configure_csgclaw, csgclaw_cli_json, + is_manager_agent_reference, manager_recreate_action_card, path_id, public_result, @@ -47,7 +48,7 @@ def script_command() -> str: def resolve_manager_app_id(args: argparse.Namespace, state: dict, result: dict) -> str: - if state.get("agent_id") == "u-manager": + if is_manager_agent_reference(str(state.get("agent_id") or "")): return str(result.get("app_id") or "").strip() try: participants = csgclaw_cli_json(args, ["participant", "list", "--channel", "feishu"]) @@ -58,8 +59,8 @@ def resolve_manager_app_id(args: argparse.Namespace, state: dict, result: dict) for participant in participants: if not isinstance(participant, dict): continue - is_manager_participant = str(participant.get("id") or "").strip() == "manager" - is_manager_agent = str(participant.get("agent_id") or "").strip() == "u-manager" + is_manager_participant = str(participant.get("id") or "").strip() in {"manager", "pt-manager"} + is_manager_agent = is_manager_agent_reference(str(participant.get("agent_id") or "")) if not (is_manager_participant or is_manager_agent): continue config = participant.get("channel_app_config") @@ -147,8 +148,8 @@ def resolve_worker_agent_reference(args: argparse.Namespace, agent_ref: str) -> def cmd_bind_manager(args: argparse.Namespace) -> int: agent_id = validate_agent_id(args.agent) - if agent_id != "u-manager": - raise RuntimeError("bind-manager currently supports only u-manager") + if not is_manager_agent_reference(agent_id): + raise RuntimeError("bind-manager supports only manager, u-manager, or agent-manager") app_id = str(args.app_id or "").strip() if not app_id: raise RuntimeError("--app-id is required") @@ -212,7 +213,7 @@ def cmd_bind_manager(args: argparse.Namespace) -> int: def cmd_start(args: argparse.Namespace) -> int: agent_ref = validate_agent_reference(args.agent) domain = args.domain - role = args.role or ("manager" if agent_ref == "u-manager" else "worker") + role = args.role or ("manager" if is_manager_agent_reference(agent_ref) else "worker") agent_id = agent_ref agent_name = "" if role == "worker": @@ -302,9 +303,9 @@ def cmd_finalize(args: argparse.Namespace) -> int: if not args.keep_state: delete_state(args, state["registration_id"]) if configured is not None: - admin_open_id = str((configured or {}).get("admin_open_id") or "").strip() if state["agent_id"] == "u-manager" else "" + admin_open_id = str((configured or {}).get("admin_open_id") or "").strip() if role == "manager" else "" else: - admin_open_id = str(result.get("open_id") or "").strip() if state["agent_id"] == "u-manager" else "" + admin_open_id = str(result.get("open_id") or "").strip() if role == "manager" else "" worker_recreate_policy = None if role == "worker": restart_status = str((ensured or {}).get("restart_status") or "") if isinstance(ensured, dict) else "" @@ -345,7 +346,7 @@ def cmd_status(args: argparse.Namespace) -> int: def cmd_recreate_agent(args: argparse.Namespace) -> int: agent_id = validate_agent_id(args.agent) - if agent_id == "u-manager": + if is_manager_agent_reference(agent_id): output = manager_recreate_action_card(agent_id) else: result = api_json(args, "POST", f"/api/v1/agents/{path_id(agent_id)}/recreate", None) @@ -371,7 +372,7 @@ def build_parser() -> argparse.ArgumentParser: add_common(start) add_api_common(start) start.add_argument("--agent", required=True, help="CSGClaw runtime Agent ID or exact display name, e.g. agent-dev or dev") - start.add_argument("--role", choices=["worker", "manager"], default="", help="Agent role; inferred from u-manager when omitted") + start.add_argument("--role", choices=["worker", "manager"], default="", help="Agent role; inferred for manager, u-manager, or agent-manager when omitted") start.add_argument("--bot-name", default="", help="CSGClaw bot display name") start.add_argument("--description", default="", help="CSGClaw bot description") start.add_argument("--domain", choices=["feishu", "lark"], default="feishu") @@ -412,7 +413,7 @@ def build_parser() -> argparse.ArgumentParser: bind_manager = sub.add_parser("bind-manager", help="Bind manager Feishu credentials and print a browser action card") add_common(bind_manager) add_api_common(bind_manager) - bind_manager.add_argument("--agent", default="u-manager", help="Manager agent id; only u-manager is supported") + bind_manager.add_argument("--agent", default="agent-manager", help="Manager reference: manager, u-manager, or agent-manager") bind_manager.add_argument("--app-id", required=True, help="Feishu app id for the manager bot app") bind_manager.add_argument("--open-id", default="", help="Optional Feishu admin open_id to bind before the bot app") bind_manager.add_argument("--name", default="", help="Optional admin participant display name") diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/csgclaw.py b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/csgclaw.py index e30b21f00..64ddf30e7 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/csgclaw.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/feishu_setup/csgclaw.py @@ -14,6 +14,11 @@ ACTION_CARD_TYPE = "csgclaw.action_card" MANAGER_REBUILD_ACTION_ID = "rebuild-manager" +MANAGER_AGENT_REFERENCES = frozenset({"manager", "u-manager", "agent-manager"}) + + +def is_manager_agent_reference(agent_ref: str) -> bool: + return (agent_ref or "").strip().casefold() in MANAGER_AGENT_REFERENCES def api_base(args) -> str: @@ -149,7 +154,7 @@ def configure_csgclaw(args, state: dict, result: dict) -> dict: def resolve_role(args, state: dict) -> str: agent_id = state["agent_id"] - return args.role or state.get("role") or ("manager" if agent_id == "u-manager" else "worker") + return args.role or state.get("role") or ("manager" if is_manager_agent_reference(agent_id) else "worker") def public_result(data: dict) -> dict: diff --git a/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py b/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py index 5bbcc5113..aefa595fd 100644 --- a/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py +++ b/internal/template/embed/manager/codex/skills/feishu/scripts/tests/test_manager_action_card.py @@ -82,6 +82,54 @@ def fake_api_json(args, method, path, body=None): self.assertEqual(api_calls, [("POST", "/api/v1/agents/u-manager/bindings:apply?channel=feishu", None)]) self.assertTrue(any("--app-secret-env" in call[0] for call in calls)) + def test_start_recognizes_all_manager_references_without_registry_lookup(self): + saved_states = [] + original_api_json = commands.api_json + original_init_registration = commands.init_registration + original_begin_registration = commands.begin_registration + original_save_state = commands.save_state + original_state_path = commands.state_path + + commands.api_json = lambda *args: self.fail(f"manager start must not query the worker registry: {args}") + commands.init_registration = lambda domain: None + commands.begin_registration = lambda domain: { + "device_code": "device-code", + "qr_url": "https://example.test/qr", + "interval": 5, + "expire_in": 600, + } + commands.save_state = lambda args, state: saved_states.append(state.copy()) + commands.state_path = lambda args, registration_id: Path("/tmp") / f"{registration_id}.json" + try: + for agent_ref in ("manager", "u-manager", "agent-manager"): + with self.subTest(agent_ref=agent_ref): + args = Namespace( + agent=agent_ref, + domain="feishu", + role="", + bot_name="", + description="", + timeout=600, + json=True, + qr=False, + state_dir="", + ) + stdout = StringIO() + with redirect_stdout(stdout): + exit_code = commands.cmd_start(args) + self.assertEqual(exit_code, 0) + payload = json.loads(stdout.getvalue()) + self.assertEqual(payload["agent_id"], agent_ref) + self.assertEqual(payload["role"], "manager") + self.assertEqual(saved_states[-1]["agent_id"], agent_ref) + self.assertEqual(saved_states[-1]["role"], "manager") + finally: + commands.api_json = original_api_json + commands.init_registration = original_init_registration + commands.begin_registration = original_begin_registration + commands.save_state = original_save_state + commands.state_path = original_state_path + def test_start_resolves_existing_worker_by_display_name_with_one_registry_request(self): api_calls = [] saved_states = []