mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.
Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
184 lines
7.0 KiB
Python
184 lines
7.0 KiB
Python
"""Regression coverage for durable ``ask_user`` choice cards.
|
|
|
|
The live event must arrive after ``tool_output`` so the settled tool trace
|
|
cannot cover/push away the card. The same payload must be persisted inside
|
|
``tool_events`` so chat history can reconstruct it after a reload.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import src.agent_loop as agent_loop
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
|
|
|
|
def _collect(gen):
|
|
async def _run():
|
|
return [chunk async for chunk in gen]
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
def _events(chunks):
|
|
events = []
|
|
for chunk in chunks:
|
|
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
|
events.append(json.loads(chunk[6:]))
|
|
return events
|
|
|
|
|
|
def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
|
payload = {
|
|
"question": "¿Qué proyecto prefieres?",
|
|
"options": [
|
|
{"label": "Análisis de reseñas"},
|
|
{"label": "Clasificación temática"},
|
|
],
|
|
"multi": False,
|
|
}
|
|
|
|
monkeypatch.setattr(agent_loop, "get_setting", lambda key, default=None: default, raising=False)
|
|
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
|
|
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10, raising=False)
|
|
|
|
async def fake_stream(_candidates, messages, **kwargs):
|
|
call = {"name": "ask_user", "arguments": json.dumps(payload, ensure_ascii=False)}
|
|
yield f'data: {json.dumps({"type": "tool_calls", "calls": [call]})}\n\n'
|
|
yield "data: [DONE]\n\n"
|
|
|
|
async def fake_execute(block, *args, **kwargs):
|
|
parsed = json.loads(block.content)
|
|
return (
|
|
"ask_user",
|
|
{
|
|
"ask_user": parsed,
|
|
"output": "Awaiting their selection.",
|
|
"exit_code": 0,
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream, raising=False)
|
|
monkeypatch.setattr(agent_loop, "execute_tool_block", fake_execute, raising=False)
|
|
|
|
chunks = _collect(
|
|
agent_loop.stream_agent_loop(
|
|
"https://api.openai.com/v1",
|
|
"gpt-4o",
|
|
[{"role": "user", "content": "Ayúdame a elegir un proyecto."}],
|
|
relevant_tools={"ask_user"},
|
|
_is_teacher_run=True,
|
|
)
|
|
)
|
|
events = _events(chunks)
|
|
|
|
tool_output_index = next(i for i, event in enumerate(events) if event.get("type") == "tool_output")
|
|
ask_user_index = next(i for i, event in enumerate(events) if event.get("type") == "ask_user")
|
|
assert tool_output_index < ask_user_index
|
|
|
|
tool_output = events[tool_output_index]
|
|
assert tool_output["ask_user"] == payload
|
|
assert "¿Qué proyecto prefieres?" in tool_output["command"]
|
|
assert "\\u00" not in tool_output["command"]
|
|
|
|
metrics = next(event["data"] for event in events if event.get("type") == "metrics")
|
|
assert metrics["tool_events"][0]["ask_user"] == payload
|
|
|
|
|
|
def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch):
|
|
"""The compact ``stream_preview`` runtime must treat ask_user as a turn boundary.
|
|
|
|
Regression for chat d8dca715: the preview fed the ask_user tool result
|
|
back to the model, so it burned a second provider round and persisted the
|
|
model's follow-up prose instead of the question.
|
|
"""
|
|
import src.clean_agent_preview as module
|
|
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
|
from src.tool_policy import ToolPolicy
|
|
from src.turn_contract import resolve_full_inventory_contract
|
|
|
|
payload = {
|
|
"question": "What would be most useful to start with?",
|
|
"options": [
|
|
{"label": "A 30-day meditation plan"},
|
|
{"label": "How to measure brain activity"},
|
|
],
|
|
"multi": False,
|
|
}
|
|
requests = []
|
|
responses = iter([
|
|
{'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1',
|
|
'function': {'name': 'ask_user',
|
|
'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]},
|
|
{'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]},
|
|
])
|
|
|
|
class Response:
|
|
def __init__(self, body): self.body = body
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *args): pass
|
|
def raise_for_status(self): pass
|
|
async def aiter_lines(self):
|
|
yield 'data: ' + json.dumps(self.body)
|
|
yield 'data: [DONE]'
|
|
|
|
class Client:
|
|
def __init__(self, **kwargs): pass
|
|
async def __aenter__(self): return self
|
|
async def __aexit__(self, *args): pass
|
|
def stream(self, *args, **kwargs):
|
|
requests.append(kwargs['json'])
|
|
return Response(next(responses))
|
|
|
|
async def execute(block, **kwargs):
|
|
parsed = json.loads(block.content)
|
|
return 'ask_user', {
|
|
'ask_user': parsed,
|
|
'output': 'Awaiting their selection.',
|
|
'exit_code': 0,
|
|
}
|
|
|
|
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
|
monkeypatch.setattr(module, 'execute_tool_block', execute)
|
|
|
|
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user')
|
|
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
|
|
raw = _collect(module.stream_preview(
|
|
endpoint_url='http://test', model='test', headers={},
|
|
messages=[{'role': 'user', 'content': 'Help me pick a project.'}],
|
|
turn_contract=contract, session_id='test', owner='test',
|
|
disabled_tools=set(), tool_policy=ToolPolicy(),
|
|
))
|
|
events = _events(raw)
|
|
|
|
# Ending the turn means no second provider round is requested.
|
|
assert len(requests) == 1
|
|
ask_user_events = [event for event in events if event.get('type') == 'ask_user']
|
|
assert len(ask_user_events) == 1
|
|
assert ask_user_events[0]['data'] == payload
|
|
# The saved assistant text is the question, not the model's follow-up prose.
|
|
final = next(event for event in events if event.get('type') == 'final_response')
|
|
assert final['content'] == payload['question']
|
|
types = [event.get('type') for event in events]
|
|
assert types.index('ask_user') < types.index('metrics')
|
|
# Reconstructable from persisted metadata, and no phantom extra rounds.
|
|
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
|
|
assert metrics['tool_events'][0]['ask_user'] == payload
|
|
assert metrics['agent_rounds'] == 1
|
|
assert metrics['clean_v3_turn'][-1] == {
|
|
'role': 'assistant', 'content': payload['question'],
|
|
}
|
|
|
|
|
|
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
|
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
|
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
|
|
|
assert "chatRenderer.renderAskUserCard(json.data || {})" in chat
|
|
assert "export function renderAskUserCard" in renderer
|
|
assert "renderAskUserCard(pendingAskUser" in renderer
|
|
assert "function _answerAskUserCards(root, text)" in renderer
|
|
assert "if (role === 'user') _answerAskUserCards(box," in renderer
|