Files
odysseus/tests/test_editor_writing_action_routing.py
T
Alexandre Teixeira f74a262f73 merge: reconcile PR 40 with current lab
Integrate lab fff55a78 into PR #40 (cc25d5ba). Lab's modular email
backend/frontend, modular settings, split stylesheets (static/style.css
stays deleted), procfs compatibility, and request-scoped TurnContract
authority win; PR #40's routing classifiers, editor/email/task features,
and style.css changes are ported into lab's module and stylesheet homes.

Integration fixes:
- settings/api.js imports ui.js under its canonical versioned URL
- browser observations keep legacy CAPTCHA/access-block evidence
- artifact turns do not re-trigger broad-web research recovery
- env reference documents PR test-tool variables; page regenerated

PR #40 defects surfaced by lab gates and fixed here:
- web_fetch generic schema drops top-level anyOf (OpenAI contract);
  the compact preview contract still requires url or urls
- get_weather registered as a brokered network read
- new lazy editor modules precached for offline use
- SearXNG pin mirrored into GPU standalone compose files
- image model picker again skips offline endpoints

Tests updated where PR #40 changed behaviour on purpose, and PR tests
moved onto lab's document_source helpers.
2026-10-01 05:03:58 +01:00

305 lines
16 KiB
Python

"""Writing-menu source text must not redirect the requested editor operation."""
import re
from types import SimpleNamespace
import pytest
from src.clean_agent_preview import (
targets_active_editor, active_editor_suggestion_request, scope_active_editor_contract,
required_active_editor_tool_choice,
)
from src.turn_contract import (
editor_request_instructions, requested_capabilities, selected_tools_for_request,
preserve_bound_editor_selected_tools, resolve_turn_contract,
)
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
from src.tool_policy import ToolPolicy
from tests.helpers.document_source import declaration
MENU = declaration('_AI_WRITING_ACTIONS')
ACTIONS = dict(re.findall(r"\s+(\w+): '([^']+)'", MENU))
@pytest.mark.parametrize('action', ['proofread', 'improve', 'concise', 'style', 'sources'])
@pytest.mark.parametrize('newline', ['\n', '\r\n'])
def test_writing_actions_keep_inline_tools_despite_source_topics(action, newline):
prompt = ACTIONS[action] + '\n\nUse this configured writing style as the source of truth:\n---\nUse clear sentences about files and tasks.\n---'
prompt += '\n\nImportant scope: work only on this selected passage. Selected passage:\n---\nThe calendar lists events. I read notes about Python scripts, images and a new document. This sentnce needs help.\n---'
prompt = prompt.replace('\n', newline)
doc = SimpleNamespace(title='Essay', language='markdown', current_content='This sentnce needs help.')
families = requested_capabilities(prompt, active_document=True)
assert families == ({'documents', 'search_browser'} if action == 'sources' else {'documents'})
assert targets_active_editor(doc, prompt)
assert active_editor_suggestion_request(doc, prompt) == (action != 'proofread')
selected = preserve_bound_editor_selected_tools(prompt, selected_tools_for_request(prompt), active_document=True)
contract = resolve_turn_contract(capabilities=families, schemas=FUNCTION_TOOL_SCHEMAS,
policy=ToolPolicy(), selected_tools=selected)
scoped = scope_active_editor_contract(contract, suggestion_only=action != 'proofread', source_verification=action == 'sources')
names = {s['function']['name'] for s in scoped.schemas()}
if action == 'proofread':
assert names == {'edit_document', 'update_document'}
return
assert 'suggest_document' in names
assert not names & {'edit_document', 'update_document', 'create_document', 'bash'}
if action == 'sources':
assert 'web_search' in names
else:
assert names == {'suggest_document'}
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
whole_draft_target=False, offered=scoped.schemas())['function']['name'] == 'suggest_document'
def test_source_boundary_preserves_trailing_instructions_and_plain_requests():
prompt = 'Proofread the open document. Selected passage:\n---\nCreate a new email.\n---\nAlso check sources online.'
assert 'Create a new email' not in editor_request_instructions(prompt)
assert 'Also check sources online.' in editor_request_instructions(prompt)
assert editor_request_instructions('Create a new email.') == 'Create a new email.'
def test_editor_requests_do_not_capture_explicit_other_targets():
doc = SimpleNamespace(title='Essay', language='markdown', current_content='Text')
for prompt in ['Write a note', 'Create a new document', 'Edit my calendar event']:
assert not targets_active_editor(doc, prompt)
@pytest.mark.parametrize('prompt', ['Fix the spelling in the open document.',
'Correct this sentence.', 'Polish this paragraph.',
'Shorten the open document.', 'Rewrite this document.'])
def test_direct_edits_share_the_capability_router(prompt):
doc = SimpleNamespace(title='Essay', language='markdown', current_content='Text')
assert requested_capabilities(prompt, active_document=True) == {'documents'}
assert targets_active_editor(doc, prompt)
assert not active_editor_suggestion_request(doc, prompt)
def test_source_review_still_requires_tool_completion_after_research():
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in {'web_search', 'suggest_document'}]
assert required_active_editor_tool_choice(active_editor_target=True, suggestion_target=True,
whole_draft_target=False, offered=schemas) == 'required'
@pytest.mark.parametrize('value', [3, None, [], 'not an object'])
def test_malformed_editor_tool_arguments_get_a_recoverable_error(value):
from src.clean_agent_preview import normalize_preview_call_args
with pytest.raises(ValueError, match='JSON object'):
normalize_preview_call_args('suggest_document', value)
@pytest.mark.asyncio
@pytest.mark.parametrize('tool,prompt,args', [
('suggest_document', 'Proofread the open document. Create inline suggestions only; do not apply changes.',
{'suggestions': [{'find': 'A sentnce.', 'replace': 'A sentence.', 'reason': 'Spelling.'}],
'more': True}),
('edit_document', 'Fix the spelling in the open document.',
{'edits': [{'find': 'A sentnce.', 'replace': 'A sentence.'}]}),
('update_document', 'Rewrite the whole open document.', {'content': 'A sentence.'}),
])
async def test_editor_loop_recovers_scalar_arguments_and_emits_editor_event(monkeypatch, tool, prompt, args):
import json
import src.clean_agent_preview as runner
from src.turn_contract import resolve_full_inventory_contract
requests, executed = [], []
deltas = iter([
{'content': 'PREMATURE_SUCCESS', 'tool_calls': [{'index': 0, 'id': 'bad', 'function': {'name': tool, 'arguments': '123'}}]},
{'tool_calls': [{'index': 0, 'id': 'good', 'function': {'name': tool, 'arguments': json.dumps(args)}}]},
{'content': 'Done.'},
])
class Response:
def __init__(self, delta): self.delta = delta
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({'choices': [{'delta': self.delta}]})
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(deltas))
async def execute(block, **kwargs):
executed.append(block)
return tool, {'exit_code': 0, 'action': 'suggest' if tool == 'suggest_document' else 'update',
'doc_id': 'fixture', 'content': 'A sentence.', 'title': 'Fixture',
'language': 'markdown', 'version': 2,
'suggestions': args.get('suggestions', [])}
import src.email_task_intent as intent_module
async def classify(*args, **kwargs):
return intent_module.EmailTaskIntent('other', (), 'Edit the open document')
monkeypatch.setattr(intent_module, 'classify_email_task', classify)
monkeypatch.setattr(runner.httpx, 'AsyncClient', Client)
monkeypatch.setattr(runner, 'execute_tool_block', execute)
policy = ToolPolicy()
# Preserve both direct writers; model chooses between targeted and full edit.
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] in
{'suggest_document', 'edit_document', 'update_document'}]
contract = resolve_full_inventory_contract(schemas=schemas, policy=policy)
doc = SimpleNamespace(id='fixture', title='Fixture', language='markdown', current_content='A sentnce.')
events = [json.loads(c[6:]) async for c in runner.stream_preview(
endpoint_url='http://test', model='Ajax', messages=[{'role': 'user', 'content': prompt}],
headers={}, turn_contract=contract, session_id='fixture', owner='fixture',
disabled_tools=set(), tool_policy=policy, active_document=doc, max_tokens=4096,
) if '[DONE]' not in c]
assert len(executed) == 1
assert requests[-1]["max_tokens"] == 256
assert not requests[-1].get("tools")
assert requests[0]['max_tokens'] == 4096
assert requests[0]['tool_choice'] == 'auto'
assert not any('PREMATURE_SUCCESS' in e.get('delta', '') for e in events)
assert requests[0]['parallel_tool_calls'] is False
assert 'parallel_tool_calls' not in requests[-1]
outputs = [e for e in events if e.get('type') == 'tool_output']
assert any(e.get('type') == 'editor_progress' for e in events)
assert outputs[0]['execution_attempted'] is False
assert 'JSON object' in outputs[0]['output']
assert outputs[1]['error'] is False
if tool == 'suggest_document':
assert any(e.get('type') == 'doc_suggestions' for e in events)
else:
assert any(e.get('type') == 'doc_update' for e in events)
@pytest.mark.asyncio
async def test_finish_after_saved_editor_batch_skips_another_model_request(monkeypatch):
import json
import src.clean_agent_preview as runner
from src.turn_contract import resolve_full_inventory_contract
requests, executed = [], []
args = {'edits': [{'find': 'A sentnce.', 'replace': 'A sentence.'}], 'more': True}
class Response:
async def __aenter__(self): return self
async def __aexit__(self, *ignored): pass
def raise_for_status(self): pass
async def aiter_lines(self):
yield 'data: ' + json.dumps({'choices': [{'delta': {'tool_calls': [
{'index': 0, 'id': 'edit', 'function': {
'name': 'edit_document', 'arguments': json.dumps(args)}}
]}}]})
yield 'data: [DONE]'
class Client:
def __init__(self, **ignored): pass
async def __aenter__(self): return self
async def __aexit__(self, *ignored): pass
def stream(self, *ignored, **kwargs):
requests.append(kwargs['json'])
return Response()
async def execute(block, **ignored):
executed.append(block)
return 'edit_document', {'exit_code': 0, 'action': 'edit', 'doc_id': 'fixture',
'content': 'A sentence.', 'title': 'Fixture',
'language': 'markdown', 'version': 2, 'applied': 1}
import src.email_task_intent as intent_module
async def classify(*ignored, **kwargs):
return intent_module.EmailTaskIntent('other', (), 'Edit the open document')
monkeypatch.setattr(intent_module, 'classify_email_task', classify)
monkeypatch.setattr(runner.httpx, 'AsyncClient', Client)
monkeypatch.setattr(runner, 'execute_tool_block', execute)
monkeypatch.setattr(runner.agent_runs, 'should_finish', lambda _session: bool(executed))
policy = ToolPolicy()
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'edit_document']
contract = resolve_full_inventory_contract(schemas=schemas, policy=policy)
doc = SimpleNamespace(id='fixture', title='Fixture', language='markdown', current_content='A sentnce.')
events = [json.loads(chunk[6:]) async for chunk in runner.stream_preview(
endpoint_url='http://test', model='Ajax', messages=[{'role': 'user', 'content': 'Fix the spelling in the open document.'}],
headers={}, turn_contract=contract, session_id='fixture', owner='fixture',
disabled_tools=set(), tool_policy=policy, active_document=doc, max_tokens=4096,
) if chunk.startswith('data: ') and '[DONE]' not in chunk]
assert len(requests) == 1
assert len(executed) == 1
assert any(e.get('type') == 'doc_update' for e in events)
assert any(e.get('type') == 'final_response' and 'saved so far' in e.get('content', '')
for e in events)
@pytest.mark.asyncio
async def test_finish_interrupts_wait_for_next_model_token():
import asyncio
from src.clean_agent_preview import preview_lines_until_finish
finish = asyncio.Event()
waiting = asyncio.Event()
released = asyncio.Event()
class SlowResponse:
async def aiter_lines(self):
waiting.set()
try:
await asyncio.Event().wait()
yield 'unreachable'
finally:
released.set()
task = asyncio.create_task(_collect_preview_lines(SlowResponse(), finish))
await asyncio.wait_for(waiting.wait(), 1)
finish.set()
assert await asyncio.wait_for(task, 1) == []
assert released.is_set()
async def _collect_preview_lines(response, finish):
from src.clean_agent_preview import preview_lines_until_finish
return [line async for line in preview_lines_until_finish(response, finish)]
def test_selection_only_edit_cannot_replace_all_occurrences():
from src.clean_agent_preview import normalize_preview_call_args
with pytest.raises(ValueError, match='Selection-only'):
normalize_preview_call_args('edit_document', {'edits': [
{'find': 'typo', 'replace': 'word', 'replace_all': True}]},
user_text='Important scope: work only on this selected passage.')
def test_concise_suggestions_must_actually_shorten_prose():
from src.clean_agent_preview import document_suggestion_quality_error
prompt = ACTIONS['concise']
original = '<p>This sentnce has an unecessary delay.</p>'
spelling_only = '<p>This sentence has an unnecessary delay.</p>'
proposed = {'suggestions': [{'find': original, 'replace': spelling_only}]}
assert 'does not make its passage more concise' in document_suggestion_quality_error(
'suggest_document', proposed, user_text=prompt)
proposed['suggestions'][0]['replace'] = '<p>This sentence drags.</p>'
assert document_suggestion_quality_error('suggest_document', proposed, user_text=prompt) is None
proposed['suggestions'][0]['replace'] = spelling_only
assert document_suggestion_quality_error('suggest_document', proposed,
user_text='Proofread the open document.') is None
def test_ajax_editor_schema_limits_batches_and_exposes_continuation():
from src.clean_agent_preview import compact_schemas
schemas = compact_schemas([s for s in FUNCTION_TOOL_SCHEMAS
if s['function']['name'] in {'edit_document', 'suggest_document'}], model='Ajax')
by_name = {s['function']['name']: s['function']['parameters']['properties'] for s in schemas}
assert by_name['edit_document']['edits']['maxItems'] == 12
assert by_name['suggest_document']['suggestions']['maxItems'] == 12
assert by_name['edit_document']['more']['type'] == 'boolean'
assert by_name['suggest_document']['more']['type'] == 'boolean'
def test_exact_edits_continue_but_suggestions_finish_after_one_bounded_set():
from src.clean_agent_preview import editor_batch_continues
assert editor_batch_continues('edit_document', {'edits': [{}] * 12})
assert not editor_batch_continues('suggest_document', {'suggestions': [{}] * 12})
assert not editor_batch_continues('suggest_document', {'suggestions': [{}], 'more': True})
assert not editor_batch_continues('edit_document', {'edits': [{}] * 11})
assert not editor_batch_continues('edit_document', {'edits': [{}] * 12, 'more': False})
assert editor_batch_continues('edit_document', {'edits': [{}], 'more': True})
def test_noop_sibling_does_not_create_a_failed_tool_card_after_a_real_edit():
import json
from src.clean_agent_preview import drop_redundant_editor_noops
good = {'function': {'name': 'edit_document', 'arguments': json.dumps({
'edits': [{'find': 'old', 'replace': 'new'}]})}}
noop = {'function': {'name': 'edit_document', 'arguments': json.dumps({
'edits': [{'find': 'already correct', 'replace': 'already correct'}]})}}
assert drop_redundant_editor_noops([good, noop]) == [good]
assert drop_redundant_editor_noops([noop]) == [noop]
assert drop_redundant_editor_noops([good, good]) == [good]