feat(webui): add guided setup flows

* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
This commit is contained in:
Xubin Ren
2026-07-13 13:11:46 +08:00
committed by GitHub
parent 791c7fd505
commit fe0717b385
92 changed files with 15058 additions and 1311 deletions
+118
View File
@@ -44,6 +44,7 @@ def _make_feishu_channel(
channel._client = MagicMock()
# _loop is only used by the WebSocket thread bridge; not needed for unit tests
channel._loop = None
channel._running = True
return channel
@@ -209,6 +210,35 @@ def test_reply_message_sync_returns_false_on_api_error() -> None:
assert ok is False
def test_reply_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
channel._client.im.v1.message.reply.side_effect = [interactive_resp, text_resp]
ok = channel._reply_message_sync(
"om_parent",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert ok is True
assert channel._client.im.v1.message.reply.call_count == 2
def test_reply_message_sync_returns_false_on_exception() -> None:
channel = _make_feishu_channel()
channel._client.im.v1.message.reply.side_effect = RuntimeError("network error")
@@ -368,6 +398,37 @@ async def test_send_fallback_to_create_when_reply_fails() -> None:
channel._client.im.v1.message.create.assert_called_once()
def test_send_message_sync_falls_back_to_text_for_interactive_error() -> None:
channel = _make_feishu_channel()
interactive_resp = MagicMock()
interactive_resp.success.return_value = False
interactive_resp.code = 230099
interactive_resp.msg = "cardid is invalid"
interactive_resp.get_log_id.return_value = "log_x"
text_resp = MagicMock()
text_resp.success.return_value = True
text_resp.data = SimpleNamespace(message_id="om_fallback")
channel._client.im.v1.message.create.side_effect = [interactive_resp, text_resp]
message_id = channel._send_message_sync(
"chat_id",
"oc_abc",
"interactive",
json.dumps(
{
"config": {"wide_screen_mode": True},
"elements": [{"tag": "markdown", "content": "fallback body"}],
},
ensure_ascii=False,
),
)
assert message_id == "om_fallback"
assert channel._client.im.v1.message.create.call_count == 2
@pytest.mark.asyncio
async def test_send_multiple_messages_all_use_reply_when_in_topic(tmp_path: Path) -> None:
"""When in a topic (has thread_id), all messages use reply API to stay in topic."""
@@ -786,6 +847,36 @@ async def test_session_key_group_no_root_id_uses_message_id() -> None:
assert bus_spy[0].session_key == "feishu:oc_abc:om_001"
@pytest.mark.asyncio
async def test_session_key_named_instance_uses_runtime_channel_namespace() -> None:
"""Named Feishu assistant instances keep group sessions separate."""
channel = _make_feishu_channel(group_policy="open")
channel.name = "feishu.product"
bus_spy = []
original_publish = channel.bus.publish_inbound
async def capture(msg):
bus_spy.append(msg)
await original_publish(msg)
channel.bus.publish_inbound = capture
channel._download_and_save_media = AsyncMock(return_value=(None, ""))
channel.transcribe_audio = AsyncMock(return_value="")
channel._add_reaction = AsyncMock(return_value=None)
event = _make_feishu_event(
chat_type="group",
content='{"text": "hello"}',
root_id="om_root123",
message_id="om_child456",
)
await channel._on_message(event)
assert len(bus_spy) == 1
assert bus_spy[0].channel == "feishu.product"
assert bus_spy[0].session_key == "feishu.product:oc_abc:om_root123"
@pytest.mark.asyncio
async def test_session_key_private_chat_no_override() -> None:
"""Private chat never overrides session key (consistent with Telegram/Slack)."""
@@ -1036,6 +1127,33 @@ def test_on_background_task_done_removes_from_set() -> None:
assert task not in channel._background_tasks
def test_on_message_sync_ignores_events_after_channel_stops() -> None:
"""Late WebSocket callbacks should not schedule work after the assistant is off."""
channel = _make_feishu_channel()
channel._running = False
channel._loop = MagicMock()
channel._loop.is_running.return_value = True
with patch("asyncio.run_coroutine_threadsafe") as schedule:
channel._on_message_sync(_make_feishu_event())
schedule.assert_not_called()
@pytest.mark.asyncio
async def test_on_message_ignores_events_after_channel_stops() -> None:
"""Stopped assistants must not react, pair, or publish stale Feishu events."""
channel = _make_feishu_channel(group_policy="open")
channel._running = False
channel._add_reaction = AsyncMock()
channel._handle_message = AsyncMock()
await channel._on_message(_make_feishu_event())
channel._add_reaction.assert_not_awaited()
channel._handle_message.assert_not_awaited()
@pytest.mark.asyncio
async def test_on_message_unauthorized_dm_sends_pairing_code_without_side_effects() -> None:
"""Unauthorized DM sender gets a pairing code but no media side effects."""