merge: integrate main and reconcile export dependencies
This commit is contained in:
@@ -260,10 +260,10 @@ def test_core_collections_are_typed() -> None:
|
||||
assert notes.items == []
|
||||
assert notes.page.limit == 20
|
||||
assert [skill.manifest.skill_id for skill in skills.items] == [
|
||||
"knowledge-assistant"
|
||||
"knowledge-assistant", "chat-operator"
|
||||
]
|
||||
assert skills.items[0].status == "ready"
|
||||
assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools"]
|
||||
assert [plugin.manifest.plugin_id for plugin in plugins.items] == ["text-tools", "chat-policy"]
|
||||
assert plugins.items[0].status == "ready"
|
||||
assert [provider.provider_id for provider in providers.items] == ["mock"]
|
||||
assert index.status == "idle"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from app.contracts import ChatRequest, ToolCall, ModelCapability, Message, ModelEventType as E
|
||||
from app.services import chat_agents, chat_retrieval
|
||||
|
||||
|
||||
def test_delegation_uses_existing_runtime_limits_and_no_network(monkeypatch):
|
||||
from app.container import container
|
||||
requests = []
|
||||
async def create(request):
|
||||
requests.append(request)
|
||||
return SimpleNamespace(run_id='run_test', status=SimpleNamespace(value='queued'), output=None, error_message=None)
|
||||
monkeypatch.setattr(container.agent, 'create_run', create)
|
||||
request = ChatRequest(provider_id='local', model='model', allow_agent=True, conversation_id='chat', messages=[], workspace_context={'file_path':'draft.md','content':'unsaved'})
|
||||
call = ToolCall(tool_call_id='call', name='agent.create', arguments={'input':'summarize'})
|
||||
result = asyncio.run(chat_agents.execute(call, request))
|
||||
assert result['status'] == 'queued'
|
||||
assert requests[0].metadata['conversation_id'] == 'chat'
|
||||
assert 'unsaved' in requests[0].input
|
||||
assert requests[0].allow_network is False
|
||||
assert 'notes.patch_markdown' in requests[0].allowed_tools
|
||||
with pytest.raises(ValueError):
|
||||
asyncio.run(chat_agents.execute(call, request.model_copy(update={'allow_agent':False})))
|
||||
|
||||
|
||||
def test_chat_delegates_once_and_keeps_snapshot_in_model_context(monkeypatch):
|
||||
calls, seen = [], []
|
||||
async def execute(call, request):
|
||||
calls.append(call)
|
||||
return {'run_id':'run_test','status':'queued'}
|
||||
monkeypatch.setattr(chat_agents, 'execute', execute)
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
seen.append(request)
|
||||
assert 'unsaved text' in request.system
|
||||
if len(seen) < 3:
|
||||
yield chat_retrieval.event(E.tool_call_start, {'tool_call_id':'call','name':'agent.create','arguments':{'input':'work'}})
|
||||
else:
|
||||
yield chat_retrieval.event(E.text_delta, {'text':'started'})
|
||||
yield chat_retrieval.event(E.done, {})
|
||||
request = ChatRequest(provider_id='local', model='model', use_rag=False, allow_agent=True, messages=[Message(role='user',content='do work')], workspace_context={'file_path':'a.md','content':'unsaved text'})
|
||||
provider = SimpleNamespace(adapter=Adapter(), config=SimpleNamespace(capabilities=[ModelCapability.chat, ModelCapability.tool_calling]))
|
||||
async def run(): return [event async for event in chat_retrieval.stream(request, provider)]
|
||||
events = asyncio.run(run())
|
||||
assert len(calls) == 1
|
||||
assert all(t.name != 'rag.search' for t in seen[0].tools)
|
||||
assert any(e.event == E.tool_call_end and e.data.get('result',{}).get('run_id') == 'run_test' for e in events)
|
||||
assert any(e.event == E.tool_call_end and e.data['status'] == 'failed' for e in events)
|
||||
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
import zipfile
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from app.services import chat_attachments as service
|
||||
from app.contracts import ChatRequest, ModelCapability
|
||||
|
||||
@pytest.mark.parametrize('suffix,name,xml,expected', [
|
||||
('.docx','word/document.xml','<document><p><t>Hello</t></p><p><t>World</t></p></document>','Hello\nWorld'),
|
||||
('.pptx','ppt/slides/slide1.xml','<slide><p><t>Title</t></p></slide>','第 1 页\nTitle'),
|
||||
])
|
||||
def test_office_text_extraction(tmp_path,suffix,name,xml,expected):
|
||||
path=tmp_path/('file'+suffix)
|
||||
with zipfile.ZipFile(path,'w') as z: z.writestr(name,xml)
|
||||
assert service.extract_document(path)==(expected,False)
|
||||
|
||||
def test_markdown_truncation_and_invalid_document(tmp_path):
|
||||
path=tmp_path/'file.md';path.write_text('a'*200001,encoding='utf-8')
|
||||
text,truncated=service.extract_document(path)
|
||||
assert len(text)==200000 and truncated
|
||||
path=tmp_path/'file.docx';path.write_bytes(b'invalid')
|
||||
with pytest.raises(zipfile.BadZipFile): service.extract_document(path)
|
||||
|
||||
def test_native_vision_precedes_registered_fallback(tmp_path):
|
||||
path=tmp_path/'image.png';path.write_bytes(b'\x89PNG\r\n\x1a\nimage')
|
||||
seen=[]
|
||||
class Adapter:
|
||||
async def list_models(self): return []
|
||||
async def complete(self,request):
|
||||
seen.append(request)
|
||||
return SimpleNamespace(text='image description')
|
||||
provider=SimpleNamespace(config=SimpleNamespace(capabilities=[ModelCapability.vision]),adapter=Adapter())
|
||||
request=ChatRequest(provider_id='mock',model='mock',messages=[])
|
||||
result=asyncio.run(service.describe_image(path,request,provider))
|
||||
assert result[1]=='native' and seen[0].messages[0].images[0].startswith('data:image/png;base64,')
|
||||
|
||||
def test_fallback_order_is_mcp_then_plugin(tmp_path,monkeypatch):
|
||||
from app.container import container
|
||||
from app.contracts import ToolDefinition
|
||||
path=tmp_path/'image.png';path.write_bytes(b'\x89PNG\r\n\x1a\nimage')
|
||||
definitions=[ToolDefinition(name='plugin.image',description='',source='plugin'),ToolDefinition(name='mcp.image',description='',source='mcp_server')]
|
||||
monkeypatch.setattr(container.tools,'definitions',lambda:definitions)
|
||||
seen=[]
|
||||
async def execute(call,context):
|
||||
seen.append(call.name)
|
||||
if call.name == 'mcp.image': raise TimeoutError('MCP timeout')
|
||||
return SimpleNamespace(success=True,output={'text':'fallback'})
|
||||
monkeypatch.setattr(container.tools,'execute',execute)
|
||||
class Adapter:
|
||||
async def list_models(self): return []
|
||||
provider=SimpleNamespace(config=SimpleNamespace(capabilities=[]),adapter=Adapter())
|
||||
request=ChatRequest(provider_id='mock',model='mock',messages=[],image_fallback_tools=['plugin.image','mcp.image'])
|
||||
result=asyncio.run(service.describe_image(path,request,provider))
|
||||
assert seen==['mcp.image','plugin.image'] and result[1]=='plugin.image'
|
||||
|
||||
|
||||
def test_audio_uses_persistent_transcription_and_returns_text_context(tmp_path,monkeypatch):
|
||||
from app.services import transcription_service as jobs
|
||||
from app.services.attachment_service import attachment_path
|
||||
path=attachment_path('audio.wav');path.parent.mkdir(parents=True,exist_ok=True);path.write_bytes(b'audio')
|
||||
seen=[]
|
||||
async def transcribe(attachment_id,**kwargs):
|
||||
seen.append((attachment_id,kwargs))
|
||||
return SimpleNamespace(status='completed',text='transcript',job_id='job_test',warnings=[])
|
||||
monkeypatch.setattr(jobs,'create_transcription',transcribe)
|
||||
request=ChatRequest(provider_id='mock',model='mock',messages=[],attachments=['audio.wav'])
|
||||
result=asyncio.run(service.prepare(request,None))
|
||||
assert seen==[('audio.wav',{'wait':True})]
|
||||
assert result.attachments==[] and 'transcript' in result.system
|
||||
assert result.metadata['chat_attachment_context'][0]['route']=='transcription:job_test'
|
||||
|
||||
|
||||
def test_legacy_ppt_reads_unicode_text_records(tmp_path,monkeypatch):
|
||||
import io,struct,olefile
|
||||
path=tmp_path/'legacy.ppt';path.write_bytes(b'compound-file-fixture')
|
||||
text='旧版演示文稿'.encode('utf-16-le');data=struct.pack('<HHI',0,4000,len(text))+text
|
||||
class Ole:
|
||||
def __enter__(self): return self
|
||||
def __exit__(self,*args): pass
|
||||
def openstream(self,name):
|
||||
assert name=='PowerPoint Document'
|
||||
return io.BytesIO(data)
|
||||
monkeypatch.setattr(olefile,'OleFileIO',lambda path:Ole())
|
||||
assert service.extract_document(path)==('旧版演示文稿',False)
|
||||
|
||||
|
||||
def test_compatible_provider_serializes_native_image_parts():
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
from app.contracts import ModelRequest, Message
|
||||
request=ModelRequest(provider_id='p',model='m',messages=[Message(role='user',content='describe',images=['data:image/png;base64,aW1hZ2U='])])
|
||||
wire=OpenAICompatibleProvider._messages(None,request)
|
||||
assert wire[0]['content']==[{'type':'text','text':'describe'},{'type':'image_url','image_url':{'url':'data:image/png;base64,aW1hZ2U='}}]
|
||||
@@ -11,7 +11,7 @@ from app.services.chat_context import prepare
|
||||
|
||||
|
||||
@pytest.mark.parametrize('enabled', [True, False])
|
||||
def test_chat_stream_retrieves_real_notes_and_emits_sources(monkeypatch, enabled):
|
||||
def test_chat_stream_does_not_presearch_notes(monkeypatch, enabled):
|
||||
received = []
|
||||
|
||||
class Adapter:
|
||||
@@ -34,14 +34,9 @@ def test_chat_stream_retrieves_real_notes_and_emits_sources(monkeypatch, enabled
|
||||
assert [e['sequence'] for e in events] == list(range(len(events)))
|
||||
assert events[-1]['event'] == 'Done'
|
||||
assert received[0].messages == request.messages
|
||||
if enabled:
|
||||
assert events[0]['event'] == 'Citation'
|
||||
assert events[0]['data']['note_id'] == note.note_id
|
||||
assert 'apple orchard knowledge' in received[0].system
|
||||
assert 'Keep original instructions' in received[0].system
|
||||
else:
|
||||
assert all(e['event'] != 'Citation' for e in events)
|
||||
assert received[0].system == request.system
|
||||
assert all(e['event'] != 'Citation' for e in events)
|
||||
assert 'apple orchard knowledge' not in received[0].system
|
||||
assert 'Keep original instructions' in received[0].system
|
||||
assert request.system == 'Keep original instructions'
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from app.contracts import ChatRequest, Message, ModelCapability, ModelEventType as E
|
||||
from app.services import chat_retrieval as service
|
||||
|
||||
|
||||
def test_stream_searches_again_and_preserves_numbers(monkeypatch):
|
||||
seen = []
|
||||
async def prepare(request):
|
||||
query = request.retrieval.query if request.retrieval else 'initial'
|
||||
return request, [{'block_id': 'a' if query == 'initial' else 'b', 'number': 1, 'content': query, 'citation_id': 'cit_blk_test'}]
|
||||
monkeypatch.setattr(service, 'prepare', prepare)
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
seen.append(request)
|
||||
if len(seen) == 1:
|
||||
yield service.event(E.text_delta, {'text': '需要补充资料。'})
|
||||
yield service.event(E.tool_call_start, {'tool_call_id': 'call', 'name': 'rag.search'})
|
||||
yield service.event(E.tool_call_delta, {'tool_call_id': 'call', 'arguments_delta': '{"query":"new"}'})
|
||||
yield service.event(E.tool_call_end, {'tool_call_id': 'call'})
|
||||
else:
|
||||
assert request.messages[-1].role.value == 'tool'
|
||||
assert '"number": 1' in request.messages[-1].content
|
||||
assert 'cit_blk_test' not in request.messages[-1].content
|
||||
assert 'block_id' not in request.messages[-1].content
|
||||
yield service.event(E.text_delta, {'text': '根据新证据 [1]'})
|
||||
yield service.event(E.usage, {'input_tokens': 10, 'output_tokens': 2})
|
||||
yield service.event(E.done, {})
|
||||
provider = SimpleNamespace(adapter=Adapter(), config=SimpleNamespace(capabilities=[ModelCapability.tool_calling]))
|
||||
request = ChatRequest(provider_id='x', model='x', messages=[Message(role='user', content='question')])
|
||||
async def run(): return [item async for item in service.stream(request, provider)]
|
||||
events = asyncio.run(run())
|
||||
assert len(seen) == 2
|
||||
assert any(e.event == E.text_delta and e.data['text'] == '\n\n' for e in events)
|
||||
assert events[0].event == E.text_delta
|
||||
assert [e.data['number'] for e in events if e.event == E.citation] == [1]
|
||||
assert sum(e.event == E.done for e in events) == 1
|
||||
assert next(e.data for e in events if e.event == E.usage) == {'input_tokens': 20, 'output_tokens': 4}
|
||||
assert [e.event for e in events].index(E.tool_call_end) > max(i for i, e in enumerate(events) if e.event == E.citation)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tool_name', ['rag.search', 'notes.update'])
|
||||
def test_loop_is_bounded_and_never_executes_write_tools(monkeypatch, tool_name):
|
||||
searches, requests = [], []
|
||||
async def prepare(request):
|
||||
searches.append(request)
|
||||
return request, []
|
||||
monkeypatch.setattr(service, 'prepare', prepare)
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
requests.append(request)
|
||||
yield service.event(E.tool_call_start, {'tool_call_id': 'same', 'name': tool_name, 'arguments': {'query': 'again'}})
|
||||
yield service.event(E.done, {})
|
||||
provider = SimpleNamespace(adapter=Adapter(), config=SimpleNamespace(capabilities=[ModelCapability.tool_calling]))
|
||||
async def run():
|
||||
return [e async for e in service.stream(ChatRequest(provider_id='x', model='x', messages=[Message(role='user', content='q')]), provider)]
|
||||
events = asyncio.run(run())
|
||||
assert len(requests) == 4
|
||||
assert requests[-1].tools == []
|
||||
assert len(searches) == (3 if tool_name == 'rag.search' else 0)
|
||||
assert len({e.data['tool_call_id'] for e in events if e.event == E.tool_call_start}) == 4
|
||||
assert events[-1].data['status'] == 'failed'
|
||||
|
||||
|
||||
def test_closing_stream_closes_provider(monkeypatch):
|
||||
closed = []
|
||||
async def prepare(request): return request, []
|
||||
monkeypatch.setattr(service, 'prepare', prepare)
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
try:
|
||||
yield service.event(E.text_delta, {'text': 'partial'})
|
||||
await asyncio.sleep(60)
|
||||
finally:
|
||||
closed.append(True)
|
||||
async def run():
|
||||
provider = SimpleNamespace(adapter=Adapter(), config=SimpleNamespace(capabilities=[ModelCapability.tool_calling]))
|
||||
events = service.stream(ChatRequest(provider_id='x', model='x', messages=[Message(role='user', content='q')]), provider)
|
||||
await anext(events)
|
||||
await events.aclose()
|
||||
asyncio.run(run())
|
||||
assert closed == [True]
|
||||
|
||||
|
||||
def test_no_search_without_a_model_call_and_timeout_allows_continuation(monkeypatch):
|
||||
called = []
|
||||
monkeypatch.setattr(service, 'SEARCH_TIMEOUT_SECONDS', .01)
|
||||
async def slow_search(request):
|
||||
called.append(True)
|
||||
await asyncio.sleep(10)
|
||||
monkeypatch.setattr(service, 'prepare', slow_search)
|
||||
requests = []
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
requests.append(request)
|
||||
if len(requests) == 1:
|
||||
assert called == []
|
||||
yield service.event(E.text_delta, {'text': '我来查看笔记。'})
|
||||
yield service.event(E.tool_call_start, {'tool_call_id': 'search', 'name': 'rag.search', 'arguments': {'query': 'q'}})
|
||||
else:
|
||||
assert 'Retrieval failed' in request.messages[-1].content
|
||||
yield service.event(E.text_delta, {'text': '检索超时,暂时无法核对笔记。'})
|
||||
yield service.event(E.done, {})
|
||||
async def run():
|
||||
provider = SimpleNamespace(adapter=Adapter(), config=SimpleNamespace(capabilities=[ModelCapability.tool_calling]))
|
||||
return [e async for e in service.stream(ChatRequest(provider_id='x', model='x', messages=[Message(role='user', content='q')]), provider)]
|
||||
events = asyncio.run(run())
|
||||
assert events[0].event == E.text_delta
|
||||
assert next(e for e in events if e.event == E.tool_call_end).data['status'] == 'failed'
|
||||
assert events[-1].data['status'] == 'completed'
|
||||
|
||||
|
||||
def test_thinking_is_replayed_on_real_compatible_wire(monkeypatch):
|
||||
import json
|
||||
import httpx
|
||||
from app.providers.openai_compatible import OpenAICompatibleProvider
|
||||
requests = []
|
||||
async def prepare(request): return request, []
|
||||
monkeypatch.setattr(service, 'prepare', prepare)
|
||||
def handler(request):
|
||||
payload = json.loads(request.content)
|
||||
requests.append(payload)
|
||||
if len(requests) == 1:
|
||||
alias = payload['tools'][0]['function']['name']
|
||||
deltas = [{'reasoning_content': 'Need '}, {'reasoning_content': 'more evidence.'},
|
||||
{'tool_calls': [{'index': i, 'id': f'call{i}', 'type': 'function', 'function': {'name': alias, 'arguments': '{"query":"Python"}'}} for i in range(2)]}]
|
||||
else:
|
||||
assistant = next(m for m in payload['messages'] if m.get('tool_calls'))
|
||||
if assistant.get('reasoning_content') != 'Need more evidence.':
|
||||
return httpx.Response(400, json={'error': {'message': 'reasoning_content required'}})
|
||||
assert {c['id'] for c in assistant['tool_calls']} == {m['tool_call_id'] for m in payload['messages'] if m['role'] == 'tool'}
|
||||
deltas = [{'content': 'Answer after retrieval'}]
|
||||
body = ''.join('data: ' + json.dumps({'choices': [{'delta': delta}]}) + '\n\n' for delta in deltas) + 'data: [DONE]\n\n'
|
||||
return httpx.Response(200, text=body, headers={'content-type': 'text/event-stream'})
|
||||
adapter = OpenAICompatibleProvider('https://provider.test', None, SimpleNamespace(resolve=lambda _: None), transport=httpx.MockTransport(handler))
|
||||
provider = SimpleNamespace(adapter=adapter, config=SimpleNamespace(capabilities=[ModelCapability.tool_calling]))
|
||||
async def run():
|
||||
return [e async for e in service.stream(ChatRequest(provider_id='x', model='x', messages=[Message(role='user', content='q')]), provider)]
|
||||
events = asyncio.run(run())
|
||||
assert len(requests) == 2
|
||||
assert not any(e.event == E.error for e in events)
|
||||
assert any(e.data.get('text') == 'Answer after retrieval' for e in events)
|
||||
@@ -0,0 +1,89 @@
|
||||
from app.services import chat_history as history
|
||||
|
||||
|
||||
def test_edits_regeneration_and_activity_survive_version_switch():
|
||||
history.create('Versions', 'versions')
|
||||
def append(id, role, content, parent=None, activity=None):
|
||||
history.append_message('versions', message_id=id, role=role, content=content, parent_message_id=parent, activity=activity)
|
||||
append('u1', 'user', 'original')
|
||||
append('a1', 'assistant', 'original answer', 'u1')
|
||||
append('u2', 'user', 'follow-up')
|
||||
append('a2', 'assistant', 'follow-up answer', 'u2')
|
||||
history.prepare_retry('versions', 'u1')
|
||||
append('u1-edit', 'user', 'edited')
|
||||
history.reserve_response('versions', 'a1-edit')
|
||||
trace = [{'type': 'thinking', 'text': 'before'}, {'type': 'tool', 'tool_call_id': 'tool'}, {'type': 'thinking', 'text': 'after'}]
|
||||
append('a1-edit', 'assistant', 'edited answer', 'u1-edit', trace)
|
||||
items, _ = history.list_messages('versions', 500, 0)
|
||||
assert [m.message_id for m in items] == ['u1-edit', 'a1-edit']
|
||||
assert items[0].versions == ['u1', 'u1-edit']
|
||||
assert items[1].activity == trace
|
||||
history.select_version('versions', 'u1')
|
||||
assert [m.message_id for m in history.list_messages('versions', 500, 0)[0]] == ['u1', 'a1', 'u2', 'a2']
|
||||
history.prepare_retry('versions', 'a1')
|
||||
history.reserve_response('versions', 'a1-new')
|
||||
append('a1-new', 'assistant', 'regenerated', 'u1')
|
||||
items, _ = history.list_messages('versions', 500, 0)
|
||||
assert [m.message_id for m in items] == ['u1', 'a1-new']
|
||||
assert items[-1].versions == ['a1', 'a1-new']
|
||||
history.select_version('versions', 'a1')
|
||||
assert history.list_messages('versions', 500, 0)[0][-1].message_id == 'a2'
|
||||
|
||||
|
||||
def test_late_response_does_not_replace_new_generation():
|
||||
history.create('Late', 'late')
|
||||
history.append_message('late', message_id='u', role='user', content='question')
|
||||
history.reserve_response('late', 'new')
|
||||
history.append_message('late', message_id='old', role='assistant', content='old', parent_message_id='u')
|
||||
assert history.list_messages('late', 500, 0)[0][-1].message_id == 'u'
|
||||
history.append_message('late', message_id='new', role='assistant', content='new', parent_message_id='u')
|
||||
assert history.list_messages('late', 500, 0)[0][-1].message_id == 'new'
|
||||
|
||||
|
||||
def test_workspace_snapshots_and_agent_links_survive_history_reload():
|
||||
history.create('Workspace', 'workspace')
|
||||
snapshot = {'file_path': 'demo.md', 'content': '# unsaved draft'}
|
||||
history.append_message('workspace', message_id='wu', role='user', content='explain', workspace_context=snapshot)
|
||||
calls = [{'tool_call_id': 'ac', 'name': 'agent.create', 'result': '{"run_id":"run_example"}'}]
|
||||
history.append_message('workspace', message_id='wa', role='assistant', content='started', tool_calls=calls)
|
||||
messages, total = history.list_messages('workspace', 100, 0)
|
||||
assert total == 2
|
||||
assert messages[0].workspace_context.model_dump() == snapshot
|
||||
assert messages[1].tool_calls == calls
|
||||
|
||||
|
||||
def test_regeneration_persists_context_per_answer_without_rewriting_original(monkeypatch):
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from app.contracts import ChatRequest, Message, ModelEvent, ModelEventType
|
||||
from app.routes import chat, utc_now
|
||||
received=[]
|
||||
class Adapter:
|
||||
async def stream(self, request):
|
||||
received.append(request)
|
||||
yield ModelEvent(event=ModelEventType.text_delta, sequence=0, data={'text':'answer'}, timestamp=utc_now())
|
||||
yield ModelEvent(event=ModelEventType.done, sequence=1, data={}, timestamp=utc_now())
|
||||
monkeypatch.setattr('app.routes.provider_or_404',lambda _:SimpleNamespace(adapter=Adapter()))
|
||||
# Keep attachment parsing out of this persistence test; the route must save raw IDs.
|
||||
async def prepare(request, provider):
|
||||
return request.model_copy(update={'attachments':[]})
|
||||
monkeypatch.setattr('app.services.chat_attachments.prepare',prepare)
|
||||
async def scenario():
|
||||
history.create('Snapshots','snapshots')
|
||||
for index,context in enumerate([{'file_path':'a.md','content':'A'},{'file_path':'b.md','content':'B'},None]):
|
||||
req=ChatRequest(provider_id='test',model='test',use_rag=False,conversation_id='snapshots',
|
||||
user_message_id='su',assistant_message_id=f'sa{index}',retry_message_id=f'sa{index-1}' if index else None,
|
||||
messages=[Message(role='user',content='explain')],workspace_context=context,attachments=[f'file{index}.md'])
|
||||
response=await chat(req)
|
||||
_=[chunk async for chunk in response.body_iterator]
|
||||
for index,path in enumerate(['a.md','b.md',None]):
|
||||
history.select_version('snapshots',f'sa{index}')
|
||||
messages,_=history.list_messages('snapshots',100,0)
|
||||
assert messages[0].workspace_context.file_path=='a.md'
|
||||
answer=messages[-1]
|
||||
assert answer.context_captured
|
||||
assert (answer.workspace_context.file_path if answer.workspace_context else None)==path
|
||||
assert answer.attachments==[f'file{index}.md']
|
||||
assert 'b.md' in received[1].system
|
||||
assert received[2].system is None
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,45 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
from typing import get_args
|
||||
import pytest
|
||||
from app.agent.markdown_tools import ComposeArguments, Format, PatchArguments, compose, patch, register
|
||||
from app.agent.tools import ToolRegistry
|
||||
from app.services import note_service
|
||||
|
||||
|
||||
@pytest.mark.parametrize('kind', get_args(Format))
|
||||
def test_all_registered_formats_compose(kind):
|
||||
result = compose(ComposeArguments(format=kind, text='Example', items=['one', 'two'], rows=[['A', 'B'], ['C', 'D']], url='https://example.com', title='Title', tags=['tag']), None)
|
||||
assert result['markdown']
|
||||
assert result['persisted'] is False
|
||||
|
||||
|
||||
def test_fences_tables_and_permissions():
|
||||
assert compose(ComposeArguments(format='code-block', text='```'), None)['markdown'].startswith('````\n')
|
||||
with pytest.raises(ValueError): compose(ComposeArguments(format='table', rows=[['a'], ['b', 'c']]), None)
|
||||
registry = ToolRegistry()
|
||||
register(registry)
|
||||
assert registry.get('notes.patch_markdown').definition.permission == 'notes.write'
|
||||
assert registry.get('markdown.compose').definition.permission is None
|
||||
|
||||
|
||||
def test_patch_preserves_unrelated_content_and_rejects_stale_version():
|
||||
async def run():
|
||||
note = await note_service.create_note(title='Patch test', markdown='before\n\nold\n\nafter', folder=None, tags=[])
|
||||
args = PatchArguments(note_id=note.note_id, expected_content_hash=hashlib.sha256(note.markdown.encode()).hexdigest(), old_text='old', new_text='> [!NOTE]\n> new')
|
||||
await patch(args, None)
|
||||
updated = await note_service.get_note(note.note_id)
|
||||
assert updated.markdown == 'before\n\n> [!NOTE]\n> new\n\nafter'
|
||||
with pytest.raises(ValueError): await patch(args, None)
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_metadata_patch_updates_index_tags():
|
||||
async def run():
|
||||
markdown = '---\ntitle: Old\ntags: [old]\n---\nBody'
|
||||
note = await note_service.create_note(title='Old', markdown=markdown, folder=None, tags=[])
|
||||
await patch(PatchArguments(note_id=note.note_id, expected_content_hash=hashlib.sha256(markdown.encode()).hexdigest(), old_text='tags: [old]', new_text='tags: [new]'), None)
|
||||
updated = await note_service.get_note(note.note_id)
|
||||
assert updated.tags == ['new']
|
||||
assert updated.markdown.endswith('Body')
|
||||
asyncio.run(run())
|
||||
@@ -595,12 +595,12 @@ def test_chat_route_closes_upstream_and_sanitizes_unexpected_errors(monkeypatch)
|
||||
monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter()))
|
||||
|
||||
async def scenario():
|
||||
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[]))
|
||||
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[], use_rag=False))
|
||||
iterator = response.body_iterator
|
||||
await anext(iterator)
|
||||
await iterator.aclose()
|
||||
assert len(closed) == 1
|
||||
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[]))
|
||||
response = await routes.chat(ChatRequest(provider_id="test", model="test", messages=[], use_rag=False))
|
||||
items = [json.loads(chunk.split("data: ")[1].strip()) async for chunk in response.body_iterator]
|
||||
assert [item["sequence"] for item in items] == [0, 1, 2]
|
||||
assert items[-1]["data"]["status"] == "failed"
|
||||
|
||||
Reference in New Issue
Block a user