mirror of
https://gitee.com/wanwujie/deer-flow
synced 2026-04-28 00:04:47 +08:00
feat(manager): add bootstrap command to initialize soul.md in correct place (#1201)
* 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 <willem.jiang@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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)
|
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
|
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()
|
client = self._get_client()
|
||||||
|
|
||||||
# Look up existing DeerFlow thread.
|
# Look up existing DeerFlow thread.
|
||||||
@@ -481,6 +481,8 @@ class ChannelManager:
|
|||||||
thread_id = await self._create_thread(client, msg)
|
thread_id = await self._create_thread(client, msg)
|
||||||
|
|
||||||
assistant_id, run_config, run_context = self._resolve_run_params(msg, thread_id)
|
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":
|
if msg.channel_name == "feishu":
|
||||||
await self._handle_streaming_chat(
|
await self._handle_streaming_chat(
|
||||||
client,
|
client,
|
||||||
@@ -635,6 +637,14 @@ class ChannelManager:
|
|||||||
parts = text.split(maxsplit=1)
|
parts = text.split(maxsplit=1)
|
||||||
command = parts[0].lower().lstrip("/")
|
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":
|
if command == "new":
|
||||||
# Create a new thread on the LangGraph Server
|
# Create a new thread on the LangGraph Server
|
||||||
client = self._get_client()
|
client = self._get_client()
|
||||||
@@ -656,7 +666,15 @@ class ChannelManager:
|
|||||||
elif command == "memory":
|
elif command == "memory":
|
||||||
reply = await self._fetch_gateway("/api/memory", "memory")
|
reply = await self._fetch_gateway("/api/memory", "memory")
|
||||||
elif command == "help":
|
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:
|
else:
|
||||||
reply = f"Unknown command: /{command}. Type /help for available commands."
|
reply = f"Unknown command: /{command}. Type /help for available commands."
|
||||||
|
|
||||||
|
|||||||
@@ -943,6 +943,230 @@ class TestChannelManager:
|
|||||||
|
|
||||||
_run(go())
|
_run(go())
|
||||||
|
|
||||||
|
def test_handle_command_bootstrap_with_text(self):
|
||||||
|
"""/bootstrap <text> 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
|
# ChannelService tests
|
||||||
|
|||||||
Reference in New Issue
Block a user