feat(chat): add workspace chat, attachments and agent delegation
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='}}]
|
||||
@@ -38,3 +38,15 @@ def test_late_response_does_not_replace_new_generation():
|
||||
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
|
||||
|
||||
@@ -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