mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 14:47:13 +00:00
Windows 兼容性修复 + Dify API 适配 + 企微 WS 加载优化 (#2470)
* fix: Windows fsync requires write access to file descriptor
- _fsync_file: os.O_RDONLY -> os.O_RDWR (Windows _commit() requires write access)
- _fsync_directory: tolerate OSError on fsync and os.open (chmod 0o700 blocks access on Windows)
* fix: Dify SSE empty data lines and upload response format mismatch
- Skip malformed/flushed SSE data lines (JSONDecodeError)
- Accept HTTP 200 besides 201 for file upload response
- Unwrap data wrapper in upload response (resp.get('data', resp))
* feat: WeCom WS mode sends empty initial stream frame for loading spinner
Send an empty reply_stream frame immediately after stream session
creation so the WeCom client shows its built-in loading indicator
while the pipeline processes the message (e.g. RAG retrieval).
* fix(compat): harden Windows, Dify, and WeCom changes
* fix(migration): clean interrupted SQLite temp files
---------
Co-authored-by: Hyu <chenhyu@proton.me>
This commit is contained in:
@@ -44,6 +44,86 @@ def test_webhook_dispatch_tasks_are_bounded():
|
||||
assert len(client._dispatch_tasks) == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_initial_stream_frame_precedes_pipeline_dispatch(monkeypatch):
|
||||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||||
|
||||
order = []
|
||||
logger = types.SimpleNamespace(
|
||||
debug=Mock(),
|
||||
error=Mock(),
|
||||
warning=Mock(),
|
||||
)
|
||||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||||
|
||||
async def parse_message(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||||
|
||||
async def reply_stream(*args, **kwargs):
|
||||
del args, kwargs
|
||||
order.append('initial-frame')
|
||||
return {}
|
||||
|
||||
async def dispatch_event(event):
|
||||
del event
|
||||
order.append('pipeline-dispatch')
|
||||
|
||||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||||
client.reply_stream = reply_stream
|
||||
client._dispatch_event = dispatch_event
|
||||
|
||||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||||
|
||||
assert order == ['initial-frame', 'pipeline-dispatch']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ws_initial_stream_failure_still_dispatches_message(monkeypatch):
|
||||
from langbot.libs.wecom_ai_bot_api import ws_client as ws_client_module
|
||||
|
||||
dispatched = []
|
||||
|
||||
class Logger:
|
||||
def __init__(self):
|
||||
self.warnings = []
|
||||
|
||||
async def debug(self, message):
|
||||
del message
|
||||
|
||||
async def error(self, message):
|
||||
raise AssertionError(message)
|
||||
|
||||
async def warning(self, message):
|
||||
self.warnings.append(message)
|
||||
|
||||
logger = Logger()
|
||||
client = WecomBotWsClient('bot-id', 'secret', logger)
|
||||
|
||||
async def parse_message(*args, **kwargs):
|
||||
del args, kwargs
|
||||
return {'msgid': 'msg-1', 'type': 'single', 'userid': 'user-1'}
|
||||
|
||||
async def reply_stream(*args, **kwargs):
|
||||
del args, kwargs
|
||||
raise ConnectionError('simulated reply failure')
|
||||
|
||||
async def dispatch_event(event):
|
||||
dispatched.append(event)
|
||||
|
||||
monkeypatch.setattr(ws_client_module, 'parse_wecom_bot_message', parse_message)
|
||||
monkeypatch.setattr(ws_client_module.wecombotevent, 'WecomBotEvent', lambda data: data)
|
||||
client.reply_stream = reply_stream
|
||||
client._dispatch_event = dispatch_event
|
||||
|
||||
await client._handle_message_callback({'headers': {'req_id': 'req-1'}, 'body': {}})
|
||||
|
||||
assert len(dispatched) == 1
|
||||
assert len(logger.warnings) == 1
|
||||
assert 'simulated reply failure' in logger.warnings[0]
|
||||
|
||||
|
||||
def test_extract_template_card_action_supports_nested_button_key():
|
||||
task_id, event_key, card_type = extract_template_card_action(
|
||||
{
|
||||
|
||||
@@ -79,6 +79,32 @@ class TestDifyWorkflowSubmitClient:
|
||||
with pytest.raises(errors.DifyAPIError, match='SSE event exceeds'):
|
||||
await anext(client._iter_sse_json(FakeResponse()))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_parser_skips_empty_data_and_done_lines(self):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
|
||||
class FakeResponse:
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield b'data:\n\ndata: {"event":"message",'
|
||||
yield b'"answer":"ok"}\n\ndata: [DONE]\n'
|
||||
|
||||
events = [event async for event in client._iter_sse_json(FakeResponse())]
|
||||
|
||||
assert events == [{'event': 'message', 'answer': 'ok'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_parser_rejects_malformed_nonempty_data(self):
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
|
||||
class FakeResponse:
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield b'data: not-json\n'
|
||||
|
||||
with pytest.raises(errors.DifyAPIError, match='not valid JSON'):
|
||||
await anext(client._iter_sse_json(FakeResponse()))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_rejects_oversized_local_file(self, tmp_path):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
@@ -93,6 +119,62 @@ class TestDifyWorkflowSubmitClient:
|
||||
with pytest.raises(ValueError, match='exceeds the size limit'):
|
||||
await dify_client.upload_file(file_path, 'person_user-1')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('status_code', 'body', 'expected_id'),
|
||||
[
|
||||
(200, b'{"data":{"id":"wrapped-id"}}', 'wrapped-id'),
|
||||
(201, b'{"id":"flat-id"}', 'flat-id'),
|
||||
],
|
||||
)
|
||||
async def test_upload_accepts_supported_success_responses(self, status_code, body, expected_id):
|
||||
from langbot.libs.dify_service_api.v1 import client
|
||||
|
||||
class FakeResponse:
|
||||
headers = {}
|
||||
|
||||
def __init__(self):
|
||||
self.status_code = status_code
|
||||
|
||||
async def aiter_bytes(self, chunk_size=None):
|
||||
del chunk_size
|
||||
yield body
|
||||
|
||||
class FakeStreamContext:
|
||||
async def __aenter__(self):
|
||||
return FakeResponse()
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback):
|
||||
del exc_type, exc, traceback
|
||||
return False
|
||||
|
||||
class FakeClient:
|
||||
def stream(self, *args, **kwargs):
|
||||
del args, kwargs
|
||||
return FakeStreamContext()
|
||||
|
||||
dify_client = client.AsyncDifyServiceClient('test-key', 'https://dify.example/v1')
|
||||
dify_client._client = FakeClient()
|
||||
|
||||
response = await dify_client.upload_file(('hello.txt', b'hello', 'text/plain'), 'person_user-1')
|
||||
|
||||
assert response['id'] == expected_id
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'body',
|
||||
[
|
||||
b'not-json',
|
||||
b'[]',
|
||||
b'{"data":null}',
|
||||
b'{"data":{}}',
|
||||
],
|
||||
)
|
||||
def test_upload_rejects_invalid_success_payload(self, body):
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
|
||||
with pytest.raises(errors.DifyAPIError):
|
||||
client._decode_upload_response(body)
|
||||
|
||||
|
||||
class TestDifyExtractTextOutput:
|
||||
"""Tests for _extract_dify_text_output method."""
|
||||
|
||||
Reference in New Issue
Block a user