ouroboros/tests/test_headless_task_title.py
Ouroboros 7deaa78b19 Name a direct Main turn on its first working tool call
The proactive namer coined a Light-model title for every fresh Main turn and
was deleted on 15.09 when direct turns stopped wearing a title. A working turn
is a task card again, so it needs a name — but a greeting must not pay for one.
The turn's event proxy already recognizes the first non-addressing tool frame
(it stamps the Stop marker on it); it now also fires a once-per-turn callback,
and the chat lane hangs the restored namer (spawn_turn_namer, the retired
daemon-thread body with its wall-clock bound, same-status enrichment, cost
refresh and task_named broadcast) on it for Main turns only. A Project-room
turn is named by its room, an addressing-only turn spawns nothing.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
2026-09-16 02:28:17 +03:00

142 lines
6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Every headless run gets an owner-facing name (owner decision 6A/4A).
A task created from the CLI or the task API carried no name at all, so its card
fell back to the status phrase and rendered "Done with warnings" twice — once as
the title, once as the chip. Naming it costs no model call: an explicit
``--title`` is authorship and fills both name slots like a promoted chat turn,
and without one the request's own first line is derived for display.
"""
from types import SimpleNamespace
import pytest
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from ouroboros.gateway.tasks import api_tasks_create
from ouroboros.project_naming import admission_names
from ouroboros.projects_registry import PROJECT_NAME_MAX
from ouroboros.task_results import load_task_result
def test_an_explicit_title_is_authorship_and_fills_both_slots():
assert admission_names({"title": "Audit the report"}, "ignored") == (
"Audit the report", "Audit the report",
)
def test_a_derived_name_is_display_only_and_leaves_title_empty():
"""`title` means "someone named this".
It sits FIRST in the presentation cascade, so a truncated prompt written
there would outrank a real name coined later. The derivation fills only the
slot the card, history replay and the Project lifecycle row actually read.
"""
title, suggested = admission_names({}, "Fix the failing test\n\nmore detail")
assert title == ""
assert suggested == "Fix the failing test"
def test_markdown_is_stripped_before_the_first_line_is_taken():
# The live incident: the Main lifecycle row read "Project › # Задача: ...".
title, suggested = admission_names(
{}, "# Задача: жёсткая многомодельная прожарка отчёта\n\nтело запроса",
)
assert title == ""
assert suggested == "Задача: жёсткая многомодельная прожарка отчёта"
def test_a_long_first_line_is_capped_like_a_project_name():
_title, suggested = admission_names({}, "https://example.com/" + "x" * 400)
assert len(suggested) == PROJECT_NAME_MAX
assert suggested.endswith("…")
def test_an_empty_request_never_invents_a_name():
assert admission_names({}, "") == ("", "")
@pytest.fixture()
def admission(tmp_path, monkeypatch):
from supervisor import workers
repo = tmp_path / "repo"
repo.mkdir()
data = tmp_path / "data"
(data / "memory").mkdir(parents=True)
(data / "memory" / "identity.md").write_text("seed identity", encoding="utf-8")
monkeypatch.setattr(workers, "WORKERS", {0: SimpleNamespace()})
monkeypatch.setattr(workers, "_WORKER_POOL_DISABLED_REASON", "")
captured, broadcasts = [], []
monkeypatch.setattr("supervisor.queue.enqueue_task", lambda task: captured.append(dict(task)) or task)
monkeypatch.setattr("supervisor.queue.persist_queue_snapshot", lambda reason="": True)
monkeypatch.setattr("ouroboros.workspace_admission.bootstrap_process_path", lambda: [])
monkeypatch.setattr(
"supervisor.message_bus.try_get_bridge",
lambda: SimpleNamespace(broadcast=lambda payload: broadcasts.append(payload)),
)
app = Starlette(routes=[Route("/api/tasks", endpoint=api_tasks_create, methods=["POST"])])
app.state.drive_root = data
app.state.repo_dir = repo
return TestClient(app), data, captured, broadcasts
def test_the_admitted_task_and_its_durable_record_carry_the_name(admission):
client, data, captured, _broadcasts = admission
response = client.post("/api/tasks", json={"description": "audit the report", "title": "Nightly audit"})
assert response.status_code == 200, response.text
assert captured[0]["title"] == "Nightly audit"
assert captured[0]["suggested_name"] == "Nightly audit"
stored = load_task_result(data, response.json()["task_id"]) or {}
assert stored.get("title") == "Nightly audit"
assert stored.get("suggested_name") == "Nightly audit"
def test_the_card_learns_the_name_from_the_first_frame(admission):
"""Without this the card paints as its status phrase until a history replay.
The frame is WS-only — never a chat.jsonl row — and the client buffers a
name that arrives before the card exists, so ordering does not matter.
"""
client, _data, _captured, broadcasts = admission
response = client.post("/api/tasks", json={"description": "Rebuild the index\nand verify"})
assert response.status_code == 200
assert broadcasts == [{
"type": "task_named",
"task_id": response.json()["task_id"],
"suggested_name": "Rebuild the index",
}]
def test_a_title_hidden_in_metadata_is_refused_like_a_project_id(admission):
client, _data, _captured, _broadcasts = admission
response = client.post(
"/api/tasks", json={"description": "x", "metadata": {"title": "sneaky"}},
)
assert response.status_code == 400
assert "title must be a top-level field, not metadata" in response.text
# The pre-existing refusal keeps its exact wording.
other = client.post("/api/tasks", json={"description": "x", "metadata": {"project_id": "p"}})
assert other.status_code == 400
assert "project_id must be a top-level field, not metadata" in other.text
def test_a_derived_name_is_not_reported_as_model_coined():
"""Turn-into-project reuses the name slot; it must not claim authorship.
`suggested_name` is filled by admission naming (a caller title, or the
request's first line), by the lazy turn namer of a working Main turn and
by the agent's own scope tools; the conversion
cannot tell them apart, so its naming reason names the SLOT it read rather
than a coiner that may not exist.
"""
import pathlib
source = (pathlib.Path(__file__).resolve().parents[1] / "ouroboros/gateway/projects.py").read_text(
encoding="utf-8",
)
assert '"proactive_namer"' not in source
assert '"preset_suggested_name"' in source
# An explicit caller title still reports itself as exactly that.
assert '"explicit_task_title"' in source