From c037ed673923818c7059174da9334c8e125c6eea Mon Sep 17 00:00:00 2001 From: JilongSun <965640067@qq.com> Date: Fri, 20 Mar 2026 16:54:11 +0800 Subject: [PATCH] feat(manager): add bootstrap command to initialize soul.md in correct place (#1201) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(manager): add bootstrap command to initialize soul.md in correct place * feat(channels): add /bootstrap command to IM channels Add a `/bootstrap` command that routes to the chat handler with `is_bootstrap: True` in the run context, allowing the agent to invoke its setup/initialization flow (e.g. `setup_agent`). - The text after `/bootstrap` is forwarded as the chat message; when omitted a default "Initialize workspace" message is used. - Feishu channels use the streaming path as with normal chat. - No changes to ChannelStore — bootstrap is stateless and triggered purely by the command. - Update /help output to include /bootstrap. - Add 5 tests covering: text/no-text variants, Feishu streaming path, thread creation, and help text. * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: accept copilot suggestion --------- Co-authored-by: Willem Jiang Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- backend/app/channels/manager.py | 22 +++- backend/tests/test_channels.py | 224 ++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+), 2 deletions(-) diff --git a/backend/app/channels/manager.py b/backend/app/channels/manager.py index 4146843..c12dde0 100644 --- a/backend/app/channels/manager.py +++ b/backend/app/channels/manager.py @@ -466,7 +466,7 @@ class ChannelManager: logger.info("[Manager] new thread created on LangGraph Server: thread_id=%s for chat_id=%s topic_id=%s", thread_id, msg.chat_id, msg.topic_id) return thread_id - async def _handle_chat(self, msg: InboundMessage) -> None: + async def _handle_chat(self, msg: InboundMessage, extra_context: dict[str, Any] | None = None) -> None: client = self._get_client() # Look up existing DeerFlow thread. @@ -481,6 +481,8 @@ class ChannelManager: thread_id = await self._create_thread(client, msg) assistant_id, run_config, run_context = self._resolve_run_params(msg, thread_id) + if extra_context: + run_context.update(extra_context) if msg.channel_name == "feishu": await self._handle_streaming_chat( client, @@ -635,6 +637,14 @@ class ChannelManager: parts = text.split(maxsplit=1) command = parts[0].lower().lstrip("/") + if command == "bootstrap": + from dataclasses import replace as _dc_replace + + chat_text = parts[1] if len(parts) > 1 else "Initialize workspace" + chat_msg = _dc_replace(msg, text=chat_text, msg_type=InboundMessageType.CHAT) + await self._handle_chat(chat_msg, extra_context={"is_bootstrap": True}) + return + if command == "new": # Create a new thread on the LangGraph Server client = self._get_client() @@ -656,7 +666,15 @@ class ChannelManager: elif command == "memory": reply = await self._fetch_gateway("/api/memory", "memory") elif command == "help": - reply = "Available commands:\n/new — Start a new conversation\n/status — Show current thread info\n/models — List available models\n/memory — Show memory status\n/help — Show this help" + reply = ( + "Available commands:\n" + "/bootstrap — Start a bootstrap session (enables agent setup)\n" + "/new — Start a new conversation\n" + "/status — Show current thread info\n" + "/models — List available models\n" + "/memory — Show memory status\n" + "/help — Show this help" + ) else: reply = f"Unknown command: /{command}. Type /help for available commands." diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index 9d5b71c..db5ed2d 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -943,6 +943,230 @@ class TestChannelManager: _run(go()) + def test_handle_command_bootstrap_with_text(self): + """/bootstrap should route to chat with is_bootstrap=True in run_context.""" + from app.channels.manager import ChannelManager + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + outbound_received = [] + + async def capture_outbound(msg): + outbound_received.append(msg) + + bus.subscribe_outbound(capture_outbound) + + mock_client = _make_mock_langgraph_client() + manager._client = mock_client + + await manager.start() + + inbound = InboundMessage( + channel_name="test", + chat_id="chat1", + user_id="user1", + text="/bootstrap setup my workspace", + msg_type=InboundMessageType.COMMAND, + ) + await bus.publish_inbound(inbound) + await _wait_for(lambda: len(outbound_received) >= 1) + await manager.stop() + + # Should go through the chat path (runs.wait), not the command reply path + mock_client.runs.wait.assert_called_once() + call_args = mock_client.runs.wait.call_args + + # The text sent to the agent should be the part after /bootstrap + assert call_args[1]["input"]["messages"][0]["content"] == "setup my workspace" + + # run_context should contain is_bootstrap=True + assert call_args[1]["context"]["is_bootstrap"] is True + + # Normal context fields should still be present + assert "thread_id" in call_args[1]["context"] + + # Should get the agent response (not a command reply) + assert outbound_received[0].text == "Hello from agent!" + + _run(go()) + + def test_handle_command_bootstrap_without_text(self): + """/bootstrap with no text should use a default message.""" + from app.channels.manager import ChannelManager + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + outbound_received = [] + + async def capture_outbound(msg): + outbound_received.append(msg) + + bus.subscribe_outbound(capture_outbound) + + mock_client = _make_mock_langgraph_client() + manager._client = mock_client + + await manager.start() + + inbound = InboundMessage( + channel_name="test", + chat_id="chat1", + user_id="user1", + text="/bootstrap", + msg_type=InboundMessageType.COMMAND, + ) + await bus.publish_inbound(inbound) + await _wait_for(lambda: len(outbound_received) >= 1) + await manager.stop() + + mock_client.runs.wait.assert_called_once() + call_args = mock_client.runs.wait.call_args + + # Default text should be used when no text is provided + assert call_args[1]["input"]["messages"][0]["content"] == "Initialize workspace" + assert call_args[1]["context"]["is_bootstrap"] is True + + _run(go()) + + def test_handle_command_bootstrap_feishu_uses_streaming(self, monkeypatch): + """/bootstrap from feishu should go through the streaming path.""" + from app.channels.manager import ChannelManager + + monkeypatch.setattr("app.channels.manager.STREAM_UPDATE_MIN_INTERVAL_SECONDS", 0.0) + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + outbound_received = [] + + async def capture_outbound(msg): + outbound_received.append(msg) + + bus.subscribe_outbound(capture_outbound) + + stream_events = [ + _make_stream_part( + "values", + { + "messages": [ + {"type": "human", "content": "hello"}, + {"type": "ai", "content": "Bootstrap done"}, + ], + "artifacts": [], + }, + ), + ] + + mock_client = _make_mock_langgraph_client() + mock_client.runs.stream = MagicMock(return_value=_make_async_iterator(stream_events)) + manager._client = mock_client + + await manager.start() + + inbound = InboundMessage( + channel_name="feishu", + chat_id="chat1", + user_id="user1", + text="/bootstrap hello", + msg_type=InboundMessageType.COMMAND, + thread_ts="om-source-1", + ) + await bus.publish_inbound(inbound) + await _wait_for(lambda: any(m.is_final for m in outbound_received)) + await manager.stop() + + # Should use streaming path (runs.stream, not runs.wait) + mock_client.runs.stream.assert_called_once() + call_args = mock_client.runs.stream.call_args + + assert call_args[1]["input"]["messages"][0]["content"] == "hello" + assert call_args[1]["context"]["is_bootstrap"] is True + + # Final message should be published + final_msgs = [m for m in outbound_received if m.is_final] + assert len(final_msgs) == 1 + assert final_msgs[0].text == "Bootstrap done" + + _run(go()) + + def test_handle_command_bootstrap_creates_thread_if_needed(self): + """/bootstrap should create a new thread when none exists.""" + from app.channels.manager import ChannelManager + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + outbound_received = [] + + async def capture_outbound(msg): + outbound_received.append(msg) + + bus.subscribe_outbound(capture_outbound) + + mock_client = _make_mock_langgraph_client(thread_id="bootstrap-thread") + manager._client = mock_client + + await manager.start() + + inbound = InboundMessage( + channel_name="test", + chat_id="chat1", + user_id="user1", + text="/bootstrap init", + msg_type=InboundMessageType.COMMAND, + ) + await bus.publish_inbound(inbound) + await _wait_for(lambda: len(outbound_received) >= 1) + await manager.stop() + + # A thread should be created + mock_client.threads.create.assert_called_once() + assert store.get_thread_id("test", "chat1") == "bootstrap-thread" + + _run(go()) + + def test_help_includes_bootstrap(self): + """/help output should mention /bootstrap.""" + from app.channels.manager import ChannelManager + + async def go(): + bus = MessageBus() + store = ChannelStore(path=Path(tempfile.mkdtemp()) / "store.json") + manager = ChannelManager(bus=bus, store=store) + + outbound_received = [] + + async def capture(msg): + outbound_received.append(msg) + + bus.subscribe_outbound(capture) + await manager.start() + + inbound = InboundMessage( + channel_name="test", + chat_id="chat1", + user_id="user1", + text="/help", + msg_type=InboundMessageType.COMMAND, + ) + await bus.publish_inbound(inbound) + await _wait_for(lambda: len(outbound_received) >= 1) + await manager.stop() + + assert "/bootstrap" in outbound_received[0].text + + _run(go()) + # --------------------------------------------------------------------------- # ChannelService tests