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:
Neos
2026-08-29 22:34:10 +08:00
committed by GitHub
parent bafdaf0033
commit e69a80f5e9
6 changed files with 320 additions and 25 deletions
@@ -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."""