diff --git a/docs/status-data-contract.md b/docs/status-data-contract.md index 107234f182..1e6292862d 100644 --- a/docs/status-data-contract.md +++ b/docs/status-data-contract.md @@ -2772,3 +2772,37 @@ The public examples under `examples/` are sanitized and can be used for demos. - Consumers should handle missing `run_history` as unavailable. - Consumers should treat `contract.ok=false` as a stronger signal than an empty attention queue. +## Explicit Todo queue overview + +`loopx todo list --goal-id --summary` selects the versioned +`todo_list_summary_v0` overview. It leaves ordinary list output and the existing +`--thin` view unchanged. The overview is available in JSON and Markdown and +helps a reader choose which Todo to inspect; it is not execution admission. + +The view reuses the canonical Todo query's role, status, Agent scope and stable +ordering. It shows at most eight records per role (sixteen for both roles). +`--limit` may lower that per-role cap. Agent-scoped hot reads retain their +existing omission of done records; explicit status/role reads retain their +existing selection. Blocked/deferred work and Monitors do not become runnable +merely because they appear in the overview. + +One `todos` array contains `todo_id`, `role`, `title`, `status`, `priority`, +`claimed_by` and `title_truncated`. Titles are bounded to 180 Unicode code +points; canonical identities are retained. Markdown marks truncated titles +with `*`. `todo list --goal-id --todo-id ` restores complete +requirements through the existing exact-read owner. `--summary` is mutually +exclusive with `--thin` and `--todo-id`. + +`todo_count` and `matched_todo_count` cover the complete query match before +display limits, `returned_todo_count` is the number displayed, and +`omitted_todo_count` is their difference. `todo_list_summary_projection` +declares the version, per-role/text limits and full-detail routes. The compact +`authority_read` retains provider identity/revision/cursor and the canonical +read/fallback facts. The existing `projection_envelope` distinguishes complete +source coverage from display truncation. An unavailable source remains an +error, never a successful empty queue. + +The new `todo_list_summary` output-budget variant has separate JSON/Markdown +ceilings. Existing default and thin budgets remain unchanged. App and Lark +callers keep their current full/exact views; this opt-in CLI projection does +not switch consumers, create writes, or replace quota/lease/acceptance checks. diff --git a/loopx/cli_commands/todo_argument_validation.py b/loopx/cli_commands/todo_argument_validation.py index 969994b3b7..6c508e8ad1 100644 --- a/loopx/cli_commands/todo_argument_validation.py +++ b/loopx/cli_commands/todo_argument_validation.py @@ -80,6 +80,7 @@ ("--agent-id", "agent_id"), ("--limit", "todo_limit"), ("--thin", "todo_thin"), + ("--summary", "todo_summary"), ("--state-file", "state_file"), ("--execute", "execute"), ("--provider-revision", "provider_revision"), @@ -142,7 +143,7 @@ # The same strict subset drives validation and action help. Complex lifecycle # commands retain full help until their conditional grammar is centralized. TODO_ACTION_FIELDS = { - "list": frozenset({"role", "todo_id", "status", "agent_id", "todo_limit", "todo_thin", "state_file", "project", "dry_run"}), + "list": frozenset({"role", "todo_id", "status", "agent_id", "todo_limit", "todo_thin", "todo_summary", "state_file", "project", "dry_run"}), "receipt": frozenset({"operation_id"}), "result-read": frozenset({"todo_id"}), "plan": frozenset({"text", "agent_id", "project"}), @@ -689,6 +690,13 @@ def validate_shared_todo_options(args: argparse.Namespace) -> None: ) if args.todo_thin and args.todo_command != "list": raise ValueError("--thin is supported only by todo list") + if getattr(args, "todo_summary", False): + if args.todo_command != "list": + raise ValueError("--summary is supported only by todo list") + if args.todo_thin: + raise ValueError("todo list accepts either --summary or --thin") + if args.todo_id: + raise ValueError("Exact Todo reads return full requirements; remove --summary or omit --todo-id for a queue overview") def validate_capability_gap_options(args: argparse.Namespace) -> None: diff --git a/loopx/cli_commands/todo_list.py b/loopx/cli_commands/todo_list.py index c24e915a75..e40082b2c5 100644 --- a/loopx/cli_commands/todo_list.py +++ b/loopx/cli_commands/todo_list.py @@ -38,6 +38,7 @@ def handle_todo_list_command( agent_id=args.agent_id, limit=args.todo_limit, thin=bool(args.todo_thin), + **({"summary": True} if getattr(args, "todo_summary", False) else {}), project=Path(args.project).expanduser() if args.project else None, state_file=Path(args.state_file).expanduser() if args.state_file else None, runtime_root_arg=runtime_root_arg, diff --git a/loopx/cli_commands/todo_registration.py b/loopx/cli_commands/todo_registration.py index f3de10804a..44dd18f66e 100644 --- a/loopx/cli_commands/todo_registration.py +++ b/loopx/cli_commands/todo_registration.py @@ -457,6 +457,17 @@ def register_todo_command( "lower but not expand that bound." ), ) + todo_parser.add_argument( + "--summary", + dest="todo_summary", + action="store_true", + help=( + "For todo list, show a versioned queue overview with at most eight " + "items per role; --limit can lower that bound. Use an exact " + "--todo-id read for complete requirements. Cannot combine with " + "--thin or --todo-id." + ), + ) todo_parser.add_argument("--project", help="Project root. Defaults to the registry goal repo.") todo_parser.add_argument("--state-file", help="Active goal state path. Defaults to the registry goal state_file.") todo_parser.add_argument("--dry-run", action="store_true", help="Preview the active-state edit without writing.") diff --git a/loopx/control_plane/testing/cli_output_budget.py b/loopx/control_plane/testing/cli_output_budget.py index 9866a68332..7fb5d117da 100644 --- a/loopx/control_plane/testing/cli_output_budget.py +++ b/loopx/control_plane/testing/cli_output_budget.py @@ -613,6 +613,20 @@ class CliOutputCommandClassification: max_chars={"json": 20_000, "markdown": 6_000}, max_lines={"json": 600, "markdown": 60}, ), + CliOutputModeVariantSpec( + variant_id="todo_list_summary", + parent_surface_id="todo_list", + command="todo list --summary", + output_formats=("json", "markdown"), + semantic_json_keys=( + "todos", "summary", "matched_todo_count", "returned_todo_count", + "omitted_todo_count", "authority_read", "todo_list_summary_projection", + "projection_envelope", + ), + markdown_anchor="# LoopX Todo List Summary", + max_chars={"json": 12_000, "markdown": 8_000}, + max_lines={"json": 320, "markdown": 40}, + ), ) diff --git a/loopx/control_plane/todos/list_readback.py b/loopx/control_plane/todos/list_readback.py index e52b836eff..b1afc9f968 100644 --- a/loopx/control_plane/todos/list_readback.py +++ b/loopx/control_plane/todos/list_readback.py @@ -32,6 +32,7 @@ def list_goal_todos( runtime_root_arg: str | None = None, limit: int | None = None, thin: bool = False, + summary: bool = False, read_scope: Literal["active", "completed_history"] = "active", json_references: bool = False, ) -> dict[str, Any]: @@ -44,6 +45,10 @@ def list_goal_todos( raise ValueError("todo_id must use the public token shape todo_") if normalized_todo_id and thin: raise ValueError("Exact Todo reads return full requirements; remove --thin or omit --todo-id for a bounded list") + if summary and thin: + raise ValueError("todo list accepts either --summary or --thin") + if normalized_todo_id and summary: + raise ValueError("Exact Todo reads return full requirements; remove --summary or omit --todo-id for a queue overview") normalized_agent_id = normalize_todo_claimed_by(agent_id) if agent_id else None if agent_id and not normalized_agent_id: raise ValueError("agent_id must be a public-safe agent token such as codex-main-control") @@ -81,7 +86,7 @@ def list_goal_todos( from ..runtime.time import now_utc now = now_utc() - params = {"kind": "query", + params: dict[str, Any] = {"kind": "query", "source": {"runtime_root": str(runtime_root.expanduser().resolve(strict=False)), "goal_id": goal_id}, "rollout_events": list(rollout_events), "rollout_event_source": rollout_events.source, "observed_at": now.timestamp(), "evaluated_at": now.isoformat(), @@ -89,6 +94,8 @@ def list_goal_todos( "todo_id": normalized_todo_id, "agent_id": normalized_agent_id, "limit": limit, "read_scope": read_scope, "json_references": json_references, "thin": thin, "state_file": str(resolved_state_file), "project": str(resolved_project) if resolved_project else None}} + if summary: + params["query"]["summary"] = True result = project_todo_read(params) if "payload" not in result: failure = result["canonical"] diff --git a/loopx/control_plane/todos/markdown.py b/loopx/control_plane/todos/markdown.py index ca238e638a..e1dc54641b 100644 --- a/loopx/control_plane/todos/markdown.py +++ b/loopx/control_plane/todos/markdown.py @@ -145,12 +145,44 @@ def _render_thin_todo_list(payload: dict[str, Any]) -> str: return "\n".join(lines) +def _render_queue_summary(payload: dict[str, Any]) -> str: + projection = payload["todo_list_summary_projection"] + authority = payload.get("authority_read") or {} + lines = ["# LoopX Todo List Summary", "", + f"- goal_id: `{payload.get('goal_id')}`", + f"- shown: `{payload.get('returned_todo_count')}` / `{payload.get('matched_todo_count')}`; omitted: `{payload.get('omitted_todo_count')}`", + f"- item_limit_per_role: `{projection['item_limit_per_role']}`", ""] + + def cell(value: Any) -> str: + return " ".join(str(value if value is not None else "—").split()).replace("|", "\\|") + + if payload["todos"]: + lines.extend(["| Role | Priority | Status | Claimed by | Todo | ID |", + "| --- | --- | --- | --- | --- | --- |"]) + for item in payload["todos"]: + cells = [cell(item.get(key)) for key in ( + "role", "priority", "status", "claimed_by", "title", "todo_id")] + if item.get("title_truncated"): + cells[4] += " *" + lines.append("| " + " | ".join(cells) + " |") + else: + lines.append("No matching Todos.") + if any(item.get("title_truncated") for item in payload["todos"]): + lines.extend(["", r"\* Title truncated; the exact read retains the full requirement."]) + lines.extend(["", f"- source: `{authority.get('source_authority')}` @ `{authority.get('provider_revision')}`", + f"- full_detail: `{projection['full_detail_cold_paths'][0]}`", + f"- readback: {projection['readback']}"]) + return "\n".join(lines) + + def render_todo_markdown(payload: dict[str, Any]) -> str: if payload.get("todo_detail_projection"): return _render_todo_detail(payload) if payload.get("command") == "project-markdown": return _render_todo_projection(payload) if payload.get("command") == "list": + if payload.get("summary"): + return _render_queue_summary(payload) if payload.get("thin"): return _render_thin_todo_list(payload) diff --git a/loopx/control_plane/todos/read_presentation.ts b/loopx/control_plane/todos/read_presentation.ts index 489e72756d..31767556de 100644 --- a/loopx/control_plane/todos/read_presentation.ts +++ b/loopx/control_plane/todos/read_presentation.ts @@ -31,6 +31,10 @@ function compactThinValue(value: unknown): unknown { return value; } +function displayItems(items: unknown[], hideDone: boolean): unknown[] { + return hideDone ? items.filter(row => !dict(row) || row.status !== "done") : items; +} + export function compactTodoReadSummary(summary: JsonObject, role: string, limit: number | null): JsonObject { const result: JsonObject = {}, lanes: JsonObject = {}; let omittedLanes = 0, omittedDicts = 0; @@ -38,7 +42,7 @@ export function compactTodoReadSummary(summary: JsonObject, role: string, limit: if (Array.isArray(value)) { const cap = limit ?? (layout._RETAINED_LANE_LIMITS as Record)[key]; if (cap === undefined) {omittedLanes += Number(nonempty(value)); continue;} - const retained = limit === null && key === "items" ? value.filter(row => !dict(row) || row.status !== "done") : value; + const retained = displayItems(value, limit === null && key === "items"); result[key] = retained.slice(0, cap).map(row => compactItem(row)); if (retained.length > cap) lanes[key] = {shown: cap, total: retained.length}; } else if (dict(value)) { @@ -114,3 +118,36 @@ export function compactThinTodoReadSummary(summary: JsonObject, details: { omitted_nonempty_dict_count: omittedDicts, full_detail_cold_path: "todo list without --thin or active state"}; return fields; } + +/** An opt-in queue overview; selection, full requirements and admission stay upstream. */ +export function compactTodoQueueSummary(payload: JsonObject, hot: boolean): JsonObject { + const limit = Math.min(8, typeof payload.explicit_limit === "number" ? payload.explicit_limit : 8); + const projection = dict(payload.todo_list_projection) ? payload.todo_list_projection : {}; + const matched = Number(projection.matched_todo_count ?? payload.todo_count ?? 0); + const todos: JsonObject[] = []; + for (const role of ["user", "agent"]) { + const group = payload[`${role}_todos`]; + if (!dict(group) || !Array.isArray(group.items)) continue; + for (const row of displayItems(group.items, hot).filter(dict).slice(0, limit)) { + const full = stripPythonWhitespace(String(row.title || row.text || "")); + todos.push({todo_id: row.todo_id, role, title: compactText(full), status: row.status, + priority: row.priority ?? null, claimed_by: row.claimed_by ?? null, + title_truncated: Array.from(full).length > 180}); + } + } + const authority = dict(payload.authority_read) ? payload.authority_read : {}; + return {ok: payload.ok, dry_run: payload.dry_run, read_only: payload.read_only, + command: payload.command, goal_id: payload.goal_id, role: payload.role, + status_filter: payload.status_filter, source: payload.source, + ...(payload.agent_id_filter === undefined ? {} : {agent_id_filter: payload.agent_id_filter}), + ...(payload.unfiltered_todo_count === undefined ? {} : {unfiltered_todo_count: payload.unfiltered_todo_count}), + todo_count: matched, matched_todo_count: matched, returned_todo_count: todos.length, + omitted_todo_count: Math.max(0, matched - todos.length), summary: true, todos, + authority_read: {source_authority: authority.source_authority, provider_revision: authority.provider_revision, + cursor: authority.cursor, decision_read_from_provider: authority.decision_read_from_provider, + legacy_fallback_used: authority.legacy_fallback_used}, + todo_list_summary_projection: {schema_version: "todo_list_summary_v0", item_limit_per_role: limit, + item_text_limit: 180, counts_cover_full_match: true, + full_detail_cold_paths: ["todo list --goal-id --todo-id ", "todo list without --summary"], + readback: "Queue overview only. Read the exact Todo for complete requirements; quota owns execution admission."}}; +} diff --git a/loopx/control_plane/todos/read_transaction.ts b/loopx/control_plane/todos/read_transaction.ts index a27de0371f..f783ff9a93 100644 --- a/loopx/control_plane/todos/read_transaction.ts +++ b/loopx/control_plane/todos/read_transaction.ts @@ -1,13 +1,14 @@ /** Canonical provider IO and full-source summaries share one read transaction. */ import type {JsonObject} from "../effect_program.ts"; -import {requireJsonObject} from "../runtime_decode.ts"; +import {requireBoolean, requireJsonObject} from "../runtime_decode.ts"; +import {EffectRuntimeRequestError} from "../effect_runtime_errors.ts"; import {readCanonicalSnapshot} from "../coordination/canonical_snapshot_page.ts"; import {readLocalCoordinationTodoSource} from "../coordination/local_authority_read.ts"; import {LOCAL_COORDINATION_TODO_SNAPSHOT_PAGE_REQUEST_SCHEMA, LOCAL_COORDINATION_TODO_LIST_RESULT_SCHEMA} from "../coordination/coordination_state_contract.generated.ts"; import {projectCanonicalTodoFields, selectTodoReadFields, projectTodoReadGroup, TODO_READ_RESULT} from "./read_projection.ts"; import {canonicalTodoReadItems, structureCanonicalTodo} from "./read_codec.ts"; -import {compactTodoReadSummary, compactThinTodoRead, todoReadProjectionContract} from "./read_presentation.ts"; +import {compactTodoReadSummary, compactThinTodoRead, compactTodoQueueSummary, todoReadProjectionContract} from "./read_presentation.ts"; import {projectTodoContextPage} from "./context_projection.ts"; import {sealProjectionEnvelope, PROJECTION_ENVELOPE_SEAL_REQUEST} from "../projection_envelope.ts"; @@ -57,11 +58,14 @@ export async function readHistoricalTodoProjection(input: JsonObject): Promise { const query = requireJsonObject(input.query, "Todo list query"); + const summary = query.summary === undefined ? false : requireBoolean(query.summary, "Todo summary view"); + if (summary && (query.thin || query.todo_id)) throw new EffectRuntimeRequestError("Todo summary cannot be combined with thin or exact reads"); const sourceName = "file_authority"; const roles: unknown[] = query.role == null ? ["user", "agent"] : [query.role]; const select = async (source: JsonObject) => selectTodoReadFields({...input, fields: source.fields, field_evaluations: source.field_evaluations, roles, status: query.status ?? null, - todo_id: query.todo_id ?? null, agent_id: query.agent_id ?? null, limit: query.limit ?? null, source_name: sourceName}); + // The queue formatter applies its own row cap and needs the unshortened titles. + todo_id: query.todo_id ?? null, agent_id: query.agent_id ?? null, limit: summary ? null : query.limit ?? null, source_name: sourceName}); let selected = await select(fields); if (selected.rejection) return selected; let projection = requireJsonObject(selected.projection, "Todo read projection"); @@ -92,7 +96,7 @@ async function todoListPayload(input: JsonObject, original: JsonObject[], fields const matchedCount = todos.length; const hot = query.read_scope !== "completed_history" && query.agent_id && query.limit == null && query.role == null && query.status == null && query.todo_id == null; - if (hot) { + if (hot && !summary) { const summaries = requireJsonObject(projection.summaries, "Todo summaries"); for (const [key, summary] of Object.entries(summaries)) summaries[key] = compactTodoReadSummary( requireJsonObject(summary, "Todo summary"), key.replace(/_todos$/, ""), null); @@ -126,7 +130,8 @@ async function todoListPayload(input: JsonObject, original: JsonObject[], fields Object.assign(payload, projection.summaries); // The existing formatter is composed here, so a public read never calls back // across runtimes for fields of the result it just received. - const result = query.todo_id ? projectTodoContextPage({detail_payload: payload}) : query.thin ? compactThinTodoRead(payload) : + const result = summary ? compactTodoQueueSummary(payload, Boolean(hot)) : + query.todo_id ? projectTodoContextPage({detail_payload: payload}) : query.thin ? compactThinTodoRead(payload) : query.json_references ? projectTodoContextPage({list_payload: payload}) : payload; const shownCount = query.todo_id ? Number(result.todo !== null) : (result.todos as JsonObject[]).length; const observed = String(input.read_observed_at); diff --git a/tests/control_plane/test_cli_output_budget.py b/tests/control_plane/test_cli_output_budget.py index dccc946edf..2d3566357a 100644 --- a/tests/control_plane/test_cli_output_budget.py +++ b/tests/control_plane/test_cli_output_budget.py @@ -689,6 +689,8 @@ def _mode_variant_commands( + ["todo", "list", "--goal-id", GOAL_ID, "--limit", "3"], "todo_list_thin": common + ["todo", "list", "--goal-id", GOAL_ID, "--thin"], + "todo_list_summary": common + + ["todo", "list", "--goal-id", GOAL_ID, "--summary"], } @@ -747,6 +749,7 @@ def test_manifest_covers_the_declared_agent_facing_surface_set() -> None: "heartbeat_prompt_full", "todo_list_limited", "todo_list_thin", + "todo_list_summary", } assert set(CLI_OUTPUT_MODE_VARIANT_BY_ID) == expected_variants assert manifest["mode_variant_count"] == len(expected_variants) diff --git a/tests/control_plane/test_todo_list_read_transport.py b/tests/control_plane/test_todo_list_read_transport.py index c9a31a63ce..1415ea0f01 100644 --- a/tests/control_plane/test_todo_list_read_transport.py +++ b/tests/control_plane/test_todo_list_read_transport.py @@ -83,7 +83,7 @@ def run(*tail, format_name="json", expected_exit=0, isolated=False): def test_unmigrated_query_variants_refuse_without_changing_source(read_cli): _, run, _, state = read_cli before = state.read_bytes() - for options in [(), ("--todo-id", OWN), ("--limit", "1"), ("--thin",)]: + for options in [(), ("--todo-id", OWN), ("--limit", "1"), ("--thin",), ("--summary",)]: result = run(*options, expected_exit=1) assert result["error_code"] == "canonical_authority_required" assert result["legacy_fallback_used"] is False diff --git a/tests/control_plane/test_todo_list_summary.py b/tests/control_plane/test_todo_list_summary.py new file mode 100644 index 0000000000..dc7c39aa9a --- /dev/null +++ b/tests/control_plane/test_todo_list_summary.py @@ -0,0 +1,181 @@ +"""Queue overviews retain source truth without becoming execution admission.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime + +from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection +from loopx.control_plane.testing.cli_output_budget import ( + CLI_OUTPUT_MODE_VARIANT_BY_ID, assert_cli_output_mode_variant, measure_cli_output, +) + +REPO = Path(__file__).resolve().parents[2] +GOAL = "queue-overview" + + +@pytest.fixture(params=["file", "sqlite"]) +def queue_cli(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + monkeypatch.setenv("LOOPX_USAGE_PING", "0") + project = tmp_path / "project with spaces" + project.mkdir() + state = project / "state.md" + state.write_text("# Disposable queue display\n", encoding="utf-8") + runtime, registry = tmp_path / "runtime", tmp_path / "registry.json" + registry.write_text(json.dumps({"common_runtime_root": str(runtime), "goals": [{ + "id": GOAL, "repo": str(project), "state_file": state.name, + "coordination": {"registered_agents": ["agent-a", "agent-b"]}, + }]}), encoding="utf-8") + rows = [] + for role in ("user", "agent"): + for index in range(9): + status = {3: "blocked", 4: "deferred", 8: "done"}.get(index, "open") + text = (("Inspect this | complete requirement\n" if index == 0 else f"{role} requirement {index} ") + + "汉🙂" * 140) + row = {"schema_version": "todo_item_v0", "todo_id": f"todo_{role}_{index}", + "role": role, "text": text, "priority": "P1", "status": status, + "done": status == "done", "archive_state": "active", "index": index + 1, + "source_section": f"{role.title()} Todo", + "task_class": "user_action" if role == "user" else "advancement_task"} + if role == "agent" and index in (0, 1): + row["claimed_by"] = "agent-a" if index == 0 else "agent-b" + if role == "user" and index in (0, 1): + row.update(task_class="user_gate", blocks_agent="agent-a" if index == 0 else "agent-b") + if role == "user" and index in (3, 4): + row["bound_agent"] = "agent-a" + if role == "agent" and index == 6: + row.update(task_class="continuous_monitor", target_key="synthetic-source", cadence="1h", + next_due_at="2090-01-01T00:00:00Z") + if status == "done": + row.update(no_followup=True, completion_continuation="no_followup") + rows.append(row) + projection = build_todo_runtime_shadow_projection(goal_id=GOAL, todos=rows, handoff_mode="soft_claim") + initialize_canonical_authority(runtime, GOAL, projection, state_path=state, provider=request.param) + prefix = [sys.executable, "-m", "loopx.cli", "--registry", str(registry), + "--runtime-root", str(runtime), "--format"] + + def run(*options, output_format="json", expected_exit=0, todo_command="list"): + result = subprocess.run([*prefix, output_format, "todo", todo_command, "--goal-id", GOAL, *options], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", timeout=60, check=False, + env={**os.environ, "PYTHONPATH": str(REPO)}) + assert result.returncode == expected_exit, result.stdout + result.stderr + return json.loads(result.stdout) if output_format == "json" else result.stdout + + yield request.param, run, state, rows + subprocess.run([sys.executable, "-c", ( + "from loopx.control_plane.effect_runtime import effect_runtime_result;" + "effect_runtime_result('runtime.shutdown', {}, retry_safe=False)")], + cwd=REPO, capture_output=True, text=True, encoding="utf-8", timeout=30, check=True) + + +def test_queue_summary_is_opt_in_bounded_and_recovers_exact_requirements(queue_cli): + provider, run, state, rows = queue_cli + original_display = state.read_bytes() + full, thin = run(), run("--thin") + summary = run("--summary") + assert summary["summary"] and summary["read_only"] + assert summary["todo_list_summary_projection"]["schema_version"] == "todo_list_summary_v0" + assert (summary["matched_todo_count"], summary["returned_todo_count"], summary["omitted_todo_count"]) == (18, 16, 2) + expected = [row["todo_id"] for role in ("user", "agent") + for row in [item for item in full["todos"] if item["role"] == role][:8]] + assert [row["todo_id"] for row in summary["todos"]] == expected + assert "user_todos" not in summary and "agent_todos" not in summary + assert "state_file" not in summary and "project" not in summary + first = summary["todos"][0] + assert set(first) == {"todo_id", "role", "title", "status", "priority", "claimed_by", "title_truncated"} + assert first["title_truncated"] and len(first["title"]) == 180 + exact = run("--todo-id", rows[0]["todo_id"]) + assert exact["todo"]["text"] == rows[0]["text"] + assert exact["todo_detail_projection"]["source_complete"] + coverage = summary["projection_envelope"]["coverage"] + assert coverage["complete"] and coverage["available_count"] == 18 + assert coverage["shown_count"] == 16 and coverage["truncated"] + assert summary["authority_read"]["source_authority"] == f"{provider}_v0" + assert summary["authority_read"]["provider_revision"] == full["authority_read"]["provider_revision"] + assert run()["todos"] == full["todos"] + assert run("--thin")["todos"] == thin["todos"] + assert thin["todo_list_field_projection"]["item_limit_per_role"] == 2 + assert state.read_bytes() == original_display + + +def test_queue_summary_preserves_filters_limits_and_hot_display_selection(queue_cli): + _, run, _, _ = queue_cli + for options, matched, returned in [(("--limit", "1"), 18, 2), + (("--limit", "99"), 18, 16), + (("--role", "agent"), 9, 8), + (("--status", "done"), 2, 2)]: + result = run("--summary", *options) + assert result["matched_todo_count"] == matched + assert result["returned_todo_count"] == returned + assert result["omitted_todo_count"] == matched - returned + lane = run("--agent-id", "agent-a") + summary = run("--summary", "--agent-id", "agent-a") + assert [row["todo_id"] for row in summary["todos"]] == [row["todo_id"] for row in lane["todos"]] + assert {row["status"] for row in summary["todos"]} >= {"open", "blocked", "deferred"} + assert "todo_agent_1" not in {row["todo_id"] for row in summary["todos"]} + assert "todo_user_1" not in {row["todo_id"] for row in summary["todos"]} + empty = run("--summary", "--role", "user", "--status", "deferred", "--agent-id", "agent-b") + assert empty["matched_todo_count"] == empty["returned_todo_count"] == 0 + assert empty["todos"] == [] + # The source and recovery path remain canonical even without the display. + _, _, state, _ = queue_cli + state.unlink() + assert run("--summary")["returned_todo_count"] == 16 + + +def test_queue_summary_limit_preserves_title_truncation_and_exact_readback(queue_cli): + _, run, state, rows = queue_cli + display = state.read_bytes() + full, thin = run(), run("--thin") + summary = run("--summary") + limited = run("--summary", "--limit", "1") + original_by_id = {row["todo_id"]: row for row in summary["todos"]} + for row in limited["todos"]: + assert row == original_by_id[row["todo_id"]] + assert row["title_truncated"] is True + assert len(row["title"]) == 180 + source = next(item for item in rows if item["todo_id"] == row["todo_id"]) + exact = run("--todo-id", row["todo_id"]) + assert exact["todo"]["text"] == source["text"] + assert exact["todo_detail_projection"]["source_complete"] + markdown = run("--summary", "--limit", "1", output_format="markdown") + assert "Title truncated; the exact read retains the full requirement." in markdown + assert run()["todos"] == full["todos"] + assert run("--thin")["todos"] == thin["todos"] + assert limited["authority_read"] == summary["authority_read"] + assert state.read_bytes() == display + + +def test_queue_summary_markdown_and_invalid_mode_combinations(queue_cli): + _, run, state, _ = queue_cli + display = state.read_bytes() + revision = run()["authority_read"]["provider_revision"] + markdown = run("--summary", output_format="markdown") + assert "# LoopX Todo List Summary" in markdown + assert "| Role | Priority | Status | Claimed by | Todo | ID |" in markdown + assert "Inspect this \\| complete requirement" in markdown + assert "todo list --goal-id --todo-id " in markdown + assert "execution admission" in markdown + for options in [("--summary", "--thin"), ("--summary", "--todo-id", "todo_user_0")]: + assert not run(*options, expected_exit=1)["ok"] + rejected = run("--summary", "--role", "agent", "--text", "Must not create a Todo", + todo_command="add", expected_exit=1) + assert "--summary" in rejected["error"] + assert run()["authority_read"]["provider_revision"] == revision + assert state.read_bytes() == display + + +def test_summary_fits_its_own_budget_in_both_public_formats(queue_cli): + _, run, _, _ = queue_cli + spec = CLI_OUTPUT_MODE_VARIANT_BY_ID["todo_list_summary"] + for output_format in ("json", "markdown"): + payload = run("--summary", output_format=output_format) + rendered = json.dumps(payload, ensure_ascii=False, indent=2) + "\n" if output_format == "json" else payload + assert_cli_output_mode_variant(spec, output_format=output_format, text=rendered, + measurement=measure_cli_output(rendered, output_format=output_format)) diff --git a/tests/control_plane_ts/todo_queue_summary.test.ts b/tests/control_plane_ts/todo_queue_summary.test.ts new file mode 100644 index 0000000000..5da923a6a8 --- /dev/null +++ b/tests/control_plane_ts/todo_queue_summary.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import {compactTodoQueueSummary, compactThinTodoRead} from "../../loopx/control_plane/todos/read_presentation.ts"; +import type {JsonObject} from "../../loopx/control_plane/effect_program.ts"; + +function source(): JsonObject { + const group = (role: string) => ({total_count: 10, items: Array.from({length: 10}, (_, index) => ({ + todo_id: `todo_${role}_${index}`, role, text: index === 0 ? "🙂".repeat(200) : `Requirement ${index}`, + status: index === 1 ? "done" : index === 2 ? "blocked" : "open", priority: "P1", + claimed_by: "agent", note: "Full evidence belongs to exact read", global_gate: true, + }))}); + return {ok: true, read_only: true, dry_run: true, command: "list", goal_id: "synthetic", + role: "all", status_filter: null, source: "file_authority", todo_count: 20, + user_todos: group("user"), agent_todos: group("agent"), + authority_read: {source_authority: "sqlite_v0", provider_revision: "revision", cursor: "20", + decision_read_from_provider: true, legacy_fallback_used: false}}; +} + +test("queue overview reports full matches and bounds Unicode titles without changing its input", () => { + const input = source(), before = structuredClone(input); + const result = compactTodoQueueSummary(input, false); + assert.deepEqual(input, before); + assert.equal(result.matched_todo_count, 20); + assert.equal(result.returned_todo_count, 16); + assert.equal(result.omitted_todo_count, 4); + const rows = result.todos as JsonObject[]; + assert.equal(Array.from(String(rows[0]!.title)).length, 180); + assert.equal(rows[0]!.title_truncated, true); + assert.equal(rows[1]!.title_truncated, false); + assert.equal(rows[1]!.status, "done"); + assert.equal(rows[2]!.status, "blocked"); + assert.equal(rows[0]!.note, undefined); + assert.equal(rows[0]!.global_gate, undefined); + assert.equal(result.user_todos, undefined); + assert.equal(result.agent_todos, undefined); + assert.equal((result.authority_read as JsonObject).provider_revision, "revision"); +}); + +test("overview reuses hot display's done omission while thin retains its v0 cap", () => { + const input = source(); + const hot = compactTodoQueueSummary(input, true); + const rows = hot.todos as JsonObject[]; + assert.equal(rows.length, 16); + assert.ok(rows.every(row => row.status !== "done")); + assert.equal(rows[1]!.todo_id, "todo_user_2"); + const limited = compactTodoQueueSummary({...input, explicit_limit: 1, + todo_list_projection: {matched_todo_count: 20}}, false); + assert.equal(limited.returned_todo_count, 2); + assert.equal(limited.omitted_todo_count, 18); + const thin = compactThinTodoRead({...input, explicit_limit: 8}); + assert.equal(thin.returned_todo_count, 4); + assert.equal((thin.todo_list_field_projection as JsonObject).schema_version, "todo_list_thin_projection_v0"); +});