Files
odysseus/tests/test_ask_user_persistence.py
T
pewdiepie-archdaemonandClaude Opus 5.5 364229f2dc feat(harness): authority, turn contract, tools, clean_agent_preview
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>
2026-10-13 13:41:53 +00:00

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