diff --git a/cargento/skills/cargento/cargento_runtime/aggregate.py b/cargento/skills/cargento/cargento_runtime/aggregate.py index f9fe22e4..32d439d7 100644 --- a/cargento/skills/cargento/cargento_runtime/aggregate.py +++ b/cargento/skills/cargento/cargento_runtime/aggregate.py @@ -274,6 +274,7 @@ class HarnessSpec: reports_rate: bool = False reports_needs_input: bool = False reports_needs_input_when: str | None = None + reports_turn_bounds: bool = True usage: UsageProvider | None = None usage_is_fetch: bool = False @@ -415,6 +416,7 @@ def default_harnesses(*, usage_fetch_enabled: bool = True) -> tuple[HarnessSpec, # feeds into it, so a wait posted from there would paint every Cursor # row on its first command. reports_needs_input=True, + reports_turn_bounds=False, usage=cursor.usage if usage_fetch_enabled else None, usage_is_fetch=True, ), @@ -493,6 +495,11 @@ def _harness_row(spec: HarnessSpec, *, found: bool) -> dict[str, Any]: # every row whose gate mechanism cannot be turned off, which is all but # one; the page cannot derive either half. "reports_needs_input_when": spec.reports_needs_input_when, + # Whether this harness can report turn bounds at all. Stated per + # harness because it is a property of the store and its collector; + # a non-working row distinguishes "no turn in progress" from a harness + # that never reports them (DRC-4548). + "reports_turn_bounds": spec.reports_turn_bounds, "error": None, } diff --git a/cargento/skills/cargento/cargento_runtime/web/next-observed.js b/cargento/skills/cargento/cargento_runtime/web/next-observed.js index 24f7f19d..cf517cee 100644 --- a/cargento/skills/cargento/cargento_runtime/web/next-observed.js +++ b/cargento/skills/cargento/cargento_runtime/web/next-observed.js @@ -133,6 +133,16 @@ function nextObservedSession(source, asks, harness, generated, shared){ const turnElapsed = nextObservedString(turn.elapsed_h); const turnEta = nextObservedString(turn.eta_h); const turnText = turnElapsed ? `${turnElapsed} into turn` + (turnEta ? ` · ${turnEta} estimated remaining` : "") : ""; + const turnReporter = Boolean( + harness && !harness.error && ( + typeof harness.reports_turn_bounds === "boolean" + ? harness.reports_turn_bounds + : (harness.key !== "cursor" && source.harness !== "cursor") + ) + ); + const turnReason = turnReporter + ? (working ? "Turn bounds not published" : "No turn in progress") + : "Harness does not report turn bounds"; return { sid: String(source.sid == null ? "" : source.sid), harness: String(source.harness == null ? "" : source.harness), @@ -146,7 +156,7 @@ function nextObservedSession(source, asks, harness, generated, shared){ stateKnown ? "Activity not published" : "No state published"), ...nextObservedPair("next", pending && pending.subject, "No pending step published"), ...nextObservedPair("where", "", "Exact location not published"), - ...nextObservedPair("turn", turnText, "Harness does not report turn bounds"), + ...nextObservedPair("turn", turnText, turnReason), blockText: blocked ? "Waiting on you" : (reporter ? "No reported block" : (gaps.includes("block state") ? "Block state could not be read" : "Harness does not report blocks")), blockKnown, diff --git a/cargento/skills/cargento/tests/test_focus.py b/cargento/skills/cargento/tests/test_focus.py index 29d0852d..b2291c16 100644 --- a/cargento/skills/cargento/tests/test_focus.py +++ b/cargento/skills/cargento/tests/test_focus.py @@ -1021,7 +1021,7 @@ def test_the_pinned_assembly_is_untouched(self) -> None: # would fail on the reader rather than on an injected token. self.assertNotIn(b' None: {spec.key for spec in REGISTRY if spec.reports_needs_input}, ) + def test_only_cursor_declares_no_turn_bounds_reporting(self) -> None: + # DRC-4548. Every harness collector except Cursor calls `turns.turn_progress` + # and reports turn bounds. Cursor records no turn bounds in its store. + self.assertEqual( + {"cursor"}, + {spec.key for spec in REGISTRY if not spec.reports_turn_bounds}, + ) + def test_the_gate_flag_matches_the_harnesses_that_actually_have_a_path(self) -> None: # The check that would have caught the defect this test was written for. # `reports_needs_input` is a hand-set bool, and the first review of the diff --git a/cargento/skills/cargento/tests/test_next_flag.py b/cargento/skills/cargento/tests/test_next_flag.py index 9ca17c26..4a98755c 100644 --- a/cargento/skills/cargento/tests/test_next_flag.py +++ b/cargento/skills/cargento/tests/test_next_flag.py @@ -64,9 +64,9 @@ def test_retired_next_query_is_not_a_page_alias(self) -> None: def test_the_canonical_loader_is_the_released_ui_bundle(self) -> None: page = frontend_page.load_page() - self.assertEqual(910_488, len(page)) + self.assertEqual(910_849, len(page)) self.assertEqual( - "566decf4aed14c56703dc55679e3bcaf53c5a539608d297568508f2f7ad3532a", + "b6de0b2823cf47137f1cb901d891a490d4963574975269a95f87356f6131da05", hashlib.sha256(page).hexdigest(), ) diff --git a/cargento/skills/cargento/tests/test_next_observed.py b/cargento/skills/cargento/tests/test_next_observed.py index ed43c446..7ddf2a16 100644 --- a/cargento/skills/cargento/tests/test_next_observed.py +++ b/cargento/skills/cargento/tests/test_next_observed.py @@ -870,3 +870,48 @@ def test_repeating_wait_unconfirmed_note_in_observed_block(self) -> None: "Unconfirmed: no positive observation in 5m; prompt may still be standing", out["unconfirmedNote"], ) + + def test_non_working_row_distinguishes_no_turn_in_progress_from_unsupported_harness( + self, + ) -> None: + # DRC-4548. A non-working row (idle or needs_input) on a harness that reports + # turn bounds renders "No turn in progress", whereas a harness that never + # reports turn bounds (e.g. Cursor) renders "Harness does not report turn bounds". + out = self._run_page_js( + """ +const obs = nextObserved({ + harnesses: [ + {key: "claude", reports_turn_bounds: true}, + {key: "cursor", reports_turn_bounds: false} + ], + sessions: [ + {harness: "claude", sid: "c-idle", project: "repo", state: "idle"}, + {harness: "claude", sid: "c-needs", project: "repo", state: "needs_input"}, + {harness: "cursor", sid: "cur-idle", project: "repo", state: "idle"}, + {harness: "cursor", sid: "cur-work", project: "repo", state: "working"} + ] +}); +const bySid = Object.fromEntries(obs.sessions.map(s => [s.sid, { + turnText: s.turnText, + turnKnown: s.turnKnown +}])); +console.log(JSON.stringify(bySid)); +""" + ) + assert isinstance(out, dict) + self.assertEqual( + {"turnText": "No turn in progress", "turnKnown": False}, + out["c-idle"], + ) + self.assertEqual( + {"turnText": "No turn in progress", "turnKnown": False}, + out["c-needs"], + ) + self.assertEqual( + {"turnText": "Harness does not report turn bounds", "turnKnown": False}, + out["cur-idle"], + ) + self.assertEqual( + {"turnText": "Harness does not report turn bounds", "turnKnown": False}, + out["cur-work"], + ) diff --git a/cargento/skills/cargento/tests/test_next_page.py b/cargento/skills/cargento/tests/test_next_page.py index ec4481ee..60e3b6a5 100644 --- a/cargento/skills/cargento/tests/test_next_page.py +++ b/cargento/skills/cargento/tests/test_next_page.py @@ -616,8 +616,8 @@ def test_load_page_preserves_its_byte_oracles(self) -> None: "b71d627fe8cc06bc4210d23c76c0ee5b2ab644ce15a44c9d617ba66fe398847d", ), "next-observed.js": ( - 30_697, - "7e2716980a691f0a0f3a1a3f979d78cc333b09deaa5d3adbacf992a1f75662fe", + 31_058, + "c4f46faa78327849dbc1dd2aa410cc53b59cda482d87c00b3ab3cb6f1f79f9e0", ), "next-attention.js": ( 56_558, @@ -707,9 +707,9 @@ def test_load_page_preserves_its_byte_oracles(self) -> None: ) assembled = frontend_page.load_page() - self.assertEqual(910_488, len(assembled)) + self.assertEqual(910_849, len(assembled)) self.assertEqual( - "566decf4aed14c56703dc55679e3bcaf53c5a539608d297568508f2f7ad3532a", + "b6de0b2823cf47137f1cb901d891a490d4963574975269a95f87356f6131da05", hashlib.sha256(assembled).hexdigest(), ) diff --git a/cargento/skills/cargento/tests/test_next_session.py b/cargento/skills/cargento/tests/test_next_session.py index b8365bd0..fd0430b9 100644 --- a/cargento/skills/cargento/tests/test_next_session.py +++ b/cargento/skills/cargento/tests/test_next_session.py @@ -83,6 +83,41 @@ def test_absent_facts_are_reasons_and_never_placeholder_values(self) -> None: self.assertNotIn("—", html) self.assertNotRegex(html, r">\s*0\s*<") + def test_turn_bounds_absence_distinguishes_no_turn_in_progress_from_unsupported_harness( + self, + ) -> None: + # DRC-4548. On non-working rows, a turn-reporting harness renders "No turn in progress", + # whereas a harness that does not report turn bounds renders "Harness does not report turn bounds". + out = self._run_page_js(""" +__els.app = {innerHTML: ""}; +nextData = { + generated: 10000, + harnesses: [ + {key: "claude", label: "Claude Code", reports_turn_bounds: true}, + {key: "cursor", label: "Cursor", reports_turn_bounds: false} + ], + sessions: [ + {harness: "claude", sid: "claude-idle", project: "repo", state: "idle"}, + {harness: "claude", sid: "claude-needs", project: "repo", state: "needs_input"}, + {harness: "cursor", sid: "cursor-idle", project: "repo", state: "idle"} + ] +}; +const results = {}; +for(const sid of ["claude-idle", "claude-needs", "cursor-idle"]){ + const s = nextData.sessions.find(row => row.sid === sid); + nextRoute = {view: "session", project: "repo", harness: s.harness, session: sid}; + renderNext(); + results[sid] = __els.app.innerHTML; +} +console.log(JSON.stringify(results)); +""") + assert isinstance(out, dict) + self.assertIn('class="next-session-absent">No turn in progressNo turn in progressHarness does not report turn bounds None: out = self.render(""" const query = document.querySelector;