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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user