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:
JilongSun
2026-03-20 16:54:11 +08:00
committed by GitHub
parent accf5b5f8e
commit c037ed6739
2 changed files with 244 additions and 2 deletions

View File

@@ -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."

View File

@@ -943,6 +943,230 @@ class TestChannelManager:
_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