diff --git a/ouroboros/tools/tool_result.py b/ouroboros/tools/tool_result.py index 4fcda8f3b..54d1673be 100644 --- a/ouroboros/tools/tool_result.py +++ b/ouroboros/tools/tool_result.py @@ -717,6 +717,7 @@ _EXACT_IDENTIFIER_CODES = MappingProxyType( "BROWSER_BACKLOG_RETIRED_SESSIONS": "LEGACY_UNAVAILABLE", "TOOL_ARG_ERROR": "TOOL_ARG_ERROR", "INVALID_ARG": "TOOL_ARG_ERROR", + "CHILD_RESULT_DISPOSITION_INVALID": "TOOL_ARG_ERROR", "TOOL_ERROR": "TOOL_ERROR", "TOOL_INTERNAL_ERROR": "TOOL_INTERNAL_ERROR", "EXECUTOR_UNAVAILABLE": "LEGACY_UNAVAILABLE", diff --git a/tests/test_child_result_disposition.py b/tests/test_child_result_disposition.py index 3fbfddb13..a251fb399 100644 --- a/tests/test_child_result_disposition.py +++ b/tests/test_child_result_disposition.py @@ -16,6 +16,17 @@ def _parent_ctx(tmp_path, task_id: str = "parent1") -> SimpleNamespace: ) +def _typed_code(text: str) -> str: + """The code the one classifier assigns to a refusal sentence (owner item I23). + + The plain-string producers below never publish a typed result, so the + identifier table is what decides whether a refused disposition is recorded as + an argument error or as a success.""" + from ouroboros.tools.tool_result import LegacyTextResultAdapter + + return LegacyTextResultAdapter.from_text("tree_note", text).code + + def _payload(child_id: str, disposition: str, result_sha256: str) -> dict: return { "type": "child_result_disposition", @@ -236,6 +247,7 @@ def test_malformed_disposition_names_every_violation_in_one_reply(tmp_path): result = _tree_note(_parent_ctx(tmp_path), "decision", "x" * 501, payload=bad) assert result.count("CHILD_RESULT_DISPOSITION_INVALID") == 1 + assert _typed_code(result) == "TOOL_ARG_ERROR" for fragment in ( "unknown key(s) supports_claims", "disposition must be one of", @@ -281,6 +293,7 @@ def test_ledger_append_renders_the_same_aggregated_violations(tmp_path): ) assert out.startswith("⚠️ CHILD_RESULT_DISPOSITION_INVALID:") + assert _typed_code(out) == "TOOL_ARG_ERROR" for fragment in ( "unknown key(s) supports_claims", "disposition must be one of", @@ -394,6 +407,8 @@ def test_batch_disposition_rejects_invalid_entries_individually(tmp_path): assert "[stranger9] ⚠️ CHILD_RESULT_LINEAGE_FORBIDDEN" in result assert "disposition must be one of" in result assert "[entry 4] ⚠️ CHILD_RESULT_DISPOSITION_INVALID: entry must be a JSON object." in result + # A partial batch stays a warning: the recorded entries are real work. + assert _typed_code(result) == "LEGACY_WARNING" rows = tree_ledger_rows("parent1", data_root=tmp_path) assert [row["payload"]["child_task_id"] for row in rows] == ["child1"] @@ -413,6 +428,7 @@ def test_batch_disposition_envelope_is_validated_atomically(tmp_path): result = _tree_note(_parent_ctx(tmp_path), "decision", "why", payload=payload) assert "CHILD_RESULT_DISPOSITION_INVALID" in result assert "atomic no-op" in result + assert _typed_code(result) == "TOOL_ARG_ERROR" wrong_kind = _tree_note( _parent_ctx(tmp_path), "note", diff --git a/tests/test_tool_classification_differential.py b/tests/test_tool_classification_differential.py index 4010b99dd..571edd92e 100644 --- a/tests/test_tool_classification_differential.py +++ b/tests/test_tool_classification_differential.py @@ -259,6 +259,14 @@ APPROVED_DELTAS: Mapping[str, Delta] = MappingProxyType({ "native:LEGACY_UNAVAILABLE:CHILD_RESULT_STALE": Delta(False, "ok", True, "unavailable", "A.B7", "join_ledger has no current child result to bind, unlike a changed result's policy denial"), "native:LEGACY_UNAVAILABLE:TASK_NOT_FOUND": Delta(False, "ok", True, "unavailable", "A.B7", "forward_to_worker has no registered target for this task id"), "native:TOOL_ARG_ERROR:CHILD_RESULT_DISPOSITION_INVALID": Delta(False, "ok", True, "argument_error", "A.B7", "join_ledger rejects malformed disposition arguments before recording them"), + # Owner item I23: a typed refusal must be recorded as a refusal, not as ok. + # The single form already publishes the typed argument error above; the batch + # envelope, the per-entry rejections and the ledger-append path return the + # same sentence as a PLAIN STRING, so only the identifier table reaches them + # (and the stored traces of every past refusal). The register is BY CODE, so + # one row covers all four producers. CHILD_RESULT_DISPOSITION_PARTIAL keeps + # its warning: those entries did record. + "CHILD_RESULT_DISPOSITION_INVALID": Delta(False, "ok", True, "argument_error", "A.I23", "a disposition the ledger refused to record is an argument error, not a success"), "native:TOOL_ARG_ERROR:ERROR": Delta(False, "ok", True, "argument_error", "A.B7", "both commit entry points reject an empty commit message before attempting a commit"), "native:TOOL_ARG_ERROR:REJECTED": Delta(False, "ok", True, "argument_error", "A.B7", "scratchpad and identity writers reject empty or malformed content before writing"), "native:TOOL_ERROR:TASK_MESSAGE_UNWRITTEN": Delta(False, "ok", True, "error", "A.B7", "forward_to_worker failed to persist the requested message"),