diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index 57b13fc2ff..205e3edcf2 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -2668,6 +2668,7 @@ const exploreResultPageSchema = z.object({ node_id: z.string(), question: z.string(), scope: z.string(), agent_id: z.string(), evidence_refs: z.array(z.string()), last_updated_at: z.string(), measurements: z.unknown().transform(normalizeExploreMeasurements).optional(), + linked_todos_available: z.boolean().optional(), linked_todos: z.array(z.object({ todo_id: z.string(), text: z.string(), status: z.string(), claimed_by: z.string(), })), @@ -2675,7 +2676,7 @@ const exploreResultPageSchema = z.object({ }); export type ExploreResultPage = z.infer; export async function fetchExploreResults(goalId: string, cursor?: string, readSourceUrl?: string) { - const query = new URLSearchParams({goal_id: goalId, ...(cursor ? {cursor} : {})}); + const query = new URLSearchParams({goal_id: goalId, view: "findings", ...(cursor ? {cursor} : {})}); const page = exploreResultPageSchema.parse(await requestJson(sourceReadApiUrl(readSourceUrl, `/api/chat/explore-results?${query}`))); if (page.goal_id !== goalId) throw new Error("Explore result Goal mismatch"); return page; diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx index e55a22465f..2ddbe099f7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-explore-results.tsx @@ -78,8 +78,8 @@ export function GoalExploreResults({goalId, zh, active = true, readSourceUrl}: {
{zh ? "记录标识" : "Record identifiers"}
{selected.finding_id} · {selected.node_id}
{sections ?
{zh ? "完整原文" : "Full original text"}

{selected.summary}

: null} -
{zh ? "关联任务" : "Linked tasks"} · {selected.linked_todos.length} - {selected.linked_todos.length ?
    {selected.linked_todos.map(todo =>
  • +
    {zh ? "关联任务" : "Linked tasks"} · {selected.linked_todos_available === false ? "—" : selected.linked_todos.length} + {selected.linked_todos_available === false ?

    {zh ? "此来源暂不提供完整的任务关联读取。" : "Complete task associations are not available from this source."}

    : selected.linked_todos.length ?
      {selected.linked_todos.map(todo =>
    • {todo.text}{statuses[todo.status] ?? todo.status} · {todo.claimed_by || "—"}

      {todo.text}

      {todo.todo_id}
    • )}
    :

    {zh ? "当前及已完成任务中未找到关联。" : "No links found in current or completed tasks."}

    } diff --git a/examples/control_plane/quota-plan-smoke.py b/examples/control_plane/quota-plan-smoke.py index 6424421542..408af749cd 100644 --- a/examples/control_plane/quota-plan-smoke.py +++ b/examples/control_plane/quota-plan-smoke.py @@ -57,7 +57,7 @@ ) -def assert_plan_shape(plan: dict, markdown: str | None = None) -> None: +def assert_plan_shape(plan: dict, markdown: str | None = None, *, expected_user_todos: int = 1) -> None: eligible_ids = [item["goal_id"] for item in plan["groups"]["eligible"]] operator_gate_ids = [item["goal_id"] for item in plan["groups"]["operator_gate"]] throttled_ids = [item["goal_id"] for item in plan["groups"]["throttled"]] @@ -73,7 +73,7 @@ def assert_plan_shape(plan: dict, markdown: str | None = None) -> None: assert gated["quota"]["blocked_action_scope"] == "gated_delivery", gated assert "operator_question" in gated, gated if "user_todos" in gated: - assert gated["user_todos"]["open_count"] == 1, gated + assert gated["user_todos"]["open_count"] == expected_user_todos, gated assert "throttled-half" not in eligible_ids, eligible_ids if markdown is not None: assert "next_automatic_turn=full-speed" in markdown, markdown @@ -1425,7 +1425,7 @@ def main() -> int: assert_slot_preview(build_quota_slot_preview(status_payload, goal_id="near-limit-half", slots=1)) with tempfile.TemporaryDirectory(prefix="loopx-quota-plan-smoke-") as tmp: cli_plan, cli_markdown = run_cli_quota_plan(Path(tmp)) - assert_plan_shape(cli_plan, cli_markdown) + assert_plan_shape(cli_plan, cli_markdown, expected_user_todos=0) with tempfile.TemporaryDirectory(prefix="loopx-quota-should-run-smoke-") as tmp: assert_throttled_cli_should_run(run_cli_throttled_should_run(Path(tmp))) with tempfile.TemporaryDirectory(prefix="loopx-quota-slot-smoke-") as tmp: diff --git a/examples/control_plane/quota_plan_fixtures.py b/examples/control_plane/quota_plan_fixtures.py index 75544c617f..22f498369c 100644 --- a/examples/control_plane/quota_plan_fixtures.py +++ b/examples/control_plane/quota_plan_fixtures.py @@ -436,6 +436,14 @@ def write_cli_fixture(root: Path, *, scoped_agents: bool = False) -> tuple[Path, + "\n", encoding="utf-8", ) + # Production reads require canonical authority; the run-ledger operator + # gate remains independent of a manually authored user Todo. + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + for record in registry_goals: + initialize_canonical_authority(runtime, record["id"], + build_todo_runtime_shadow_projection(goal_id=record["id"], todos=[], handoff_mode="soft_claim"), + state_path=project / record["state_file"], provider="sqlite") return registry_path, runtime, project diff --git a/examples/personal-workspace-browser/evidence/source-findings/desktop.png b/examples/personal-workspace-browser/evidence/source-findings/desktop.png new file mode 100644 index 0000000000..d40495c0b9 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/source-findings/desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/source-findings/mobile.png b/examples/personal-workspace-browser/evidence/source-findings/mobile.png new file mode 100644 index 0000000000..6e3a3dc277 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/source-findings/mobile.png differ diff --git a/examples/personal-workspace-browser/research-history.mjs b/examples/personal-workspace-browser/research-history.mjs index ecad74edb6..e7bf712212 100644 --- a/examples/personal-workspace-browser/research-history.mjs +++ b/examples/personal-workspace-browser/research-history.mjs @@ -908,6 +908,34 @@ Path(sys.argv[1]).write_text(json.dumps({'goal_id':goal,'display_name':'Syntheti await page.getByRole('button', {name: 'About deep analysis', exact: true}).click(); await page.getByText('This action reuses existing judgments without a model call; original usage stays with the previous version.', {exact: true}).waitFor(); await page.keyboard.press('Escape'); + // A source can expose complete saved findings without a complete Task + // association endpoint. Unknown links must never be presented as zero. + await page.route("**/api/chat/explore-results?*", route => { + const query = new URL(route.request().url()).searchParams; + if (query.get("view") === "history") return route.fallback(); + assert.equal(query.get("view"), "findings"); + return route.fulfill({json: {ok: true, goal_id: query.get("goal_id"), total: 2, next_cursor: null, + items: [0, 1].map(index => ({finding_id: `saved-${index}`, node_id: "question", finding: `Saved observation ${index + 1}`, + question: "Does this observation support the original objective?", scope: "Fixed synthetic input", + summary: "The measured change did not improve the original objective. Keep the negative result and investigate the next unresolved question. " + + "The observation applies to the fixed input only; comparison with a different input remains unverified.", + status: "refuted", agent_id: "researcher", evidence_refs: ["artifact:fixed-input"], + last_updated_at: "2026-01-01T12:00:00Z", linked_todos: [], linked_todos_available: false}))}}); + }); + await page.getByText("Read saved findings", {exact: true}).click(); + const sourceReader = page.getByRole("region", {name: "Explore evidence", exact: true}); + const sourceBody = sourceReader.locator(".goal-team-result-reader"); + await sourceBody.waitFor(); + await sourceBody.getByText("Linked tasks · —", {exact: true}).click(); + assert.match(await sourceBody.innerText(), /Complete task associations are not available/); + assert.doesNotMatch(await sourceBody.innerText(), /No links found/); + await page.setViewportSize({width: 1440, height: 1000}); + await sourceReader.evaluate(element => element.scrollIntoView({block: "start"})); + await page.screenshot({path: resolve(outputDir, "source-saved-findings-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await sourceBody.scrollIntoViewIfNeeded(); + assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)); + await page.screenshot({path: resolve(outputDir, "source-saved-findings-mobile.png"), animations: "disabled"}); assert.equal(analysisWrites, 0, "Cached, reload, switch and keyboard reading never request execution"); assert.equal(api.turnRequests.length, 0, "Reading the graph cannot start an Agent"); return {note: "Three unchanged real checkpoint histories through production HTTP + packaged UI; per-Goal complete pagination, genuine negative evidence, no invented edges, reload/back/forward, mobile and failed refresh. Separate synthetic coincident-target regression; no benchmark qualification or model dispatch.", diff --git a/loopx/control_plane/status/goal_source.py b/loopx/control_plane/status/goal_source.py index 0dec70150e..92c2e5a724 100644 --- a/loopx/control_plane/status/goal_source.py +++ b/loopx/control_plane/status/goal_source.py @@ -289,6 +289,25 @@ def history(self, goal_id, cursor=""): self._invalidate() raise + def findings(self, goal_id, cursor="", *, projector): + """Read saved evidence; this transport has no complete task-list view.""" + with self._lock: + if goal_id is None: + raise SourceScopeError("Choose a Goal before reading findings") + if not isinstance(cursor, str) or len(cursor) > 512: + raise ValueError("history_cursor_expired") + self.status(goal_id) + try: + page = self._pages.page( + scope=(self.source.namespace, goal_id, "findings"), cursor=cursor, + load=lambda: projector(self.source.read_events(goal_id), + None, goal_id=goal_id)) + return {**page, "source": self.source.describe(), "goal_id": goal_id} + except (ValueError, OSError, subprocess.SubprocessError) as exc: + if str(exc) != "history_cursor_expired": + self._invalidate() + raise + class _NoRedirect(HTTPRedirectHandler): def redirect_request(self, *_args, **_kwargs): diff --git a/loopx/presentation/explore_results_api.py b/loopx/presentation/explore_results_api.py index 33c8224dd6..73151abb3f 100644 --- a/loopx/presentation/explore_results_api.py +++ b/loopx/presentation/explore_results_api.py @@ -10,6 +10,7 @@ explore_history_rows, explore_result_log_path, load_explore_result_events_strict, + validate_explore_result_event, ) @@ -17,10 +18,6 @@ def _result_rows(runtime_root, goal_id, registry_path): events = load_explore_result_events_strict( explore_result_log_path(runtime_root, goal_id), goal_id=goal_id, ) - projection = build_explore_result_projection( - events, goal_id=goal_id, finding_limit=len(events), - ) - nodes = {node["node_id"]: node for node in projection["nodes"]} # Reuse canonical active/history readback; an association is not adoption. todos = {} for view in ({}, {"status": "done", "read_scope": "completed_history"}): @@ -29,8 +26,18 @@ def _result_rows(runtime_root, goal_id, registry_path): goal_id=goal_id, role="agent", **view, )["todos"]: todos[todo["todo_id"]] = todo + return _finding_rows(events, todos.values(), goal_id=goal_id) + + +def _finding_rows(events, todos, *, goal_id): + """Same Explore projection and task associations for every physical source.""" + events = [validate_explore_result_event(event, expected_goal_id=goal_id) for event in events] + projection = build_explore_result_projection( + events, goal_id=goal_id, finding_limit=len(events), + ) + nodes = {node["node_id"]: node for node in projection["nodes"]} links = {} - for todo in todos.values(): + for todo in todos or []: for node_id in todo.get("explore_result_node_refs") or []: links.setdefault(node_id, []).append({ "todo_id": todo["todo_id"], "text": todo["text"], @@ -38,6 +45,7 @@ def _result_rows(runtime_root, goal_id, registry_path): }) return [ {**finding, "linked_todos": links.get(finding["node_id"], []), + **({"linked_todos_available": False} if todos is None else {}), "question": nodes.get(finding["node_id"], {}).get("title", ""), "scope": nodes.get(finding["node_id"], {}).get("summary", "")} for finding in projection["findings"] @@ -58,31 +66,41 @@ def _history_rows(runtime_root, goal_id): class ExploreResultsRequestMixin: def _explore_results(self): - query = parse_qs(urlparse(self.path).query) + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) goal_id = query.get("goal_id", [""])[0] try: + if any(len(values) != 1 for values in query.values()): + self._send_error("Choose one Goal, source and Explore view.", status=400) + return + view = query.get("view", ["findings"])[0] + if view not in {"findings", "history"}: + self._send_error("Unknown Explore view.", status=400) + return from .goal_source_routing import linked_goal_source resolved = linked_goal_source(self.server, goal_id) if resolved is not None: if not self._require_loopback_origin(): return - if query.get("view", ["history"]) != ["history"]: - self._send_error("Use the original source history.", status=400) - return row, reader = resolved if query.get("source_namespace") != [reader.source.namespace]: self._send_error("Source identity changed; refresh the Goal.", status=409) return - self._send_json(reader.history(goal_id, query.get("cursor", [""])[0])) + cursor = query.get("cursor", [""])[0] + page = (reader.history(goal_id, cursor) if view == "history" else + reader.findings(goal_id, cursor, projector=_finding_rows)) + latest = linked_goal_source(self.server, goal_id) + if latest is None or latest[1].source.namespace != reader.source.namespace: + self._send_error("Source identity changed; refresh the Goal.", status=409) + return + self._send_json(page) + return + if "source_namespace" in query: + self._send_error("Source identity changed; refresh the Goal.", status=409) return runtime_root = self._goal_result_scope(goal_id) if runtime_root is None: return # Local HTTP view selection; canonical event vocabularies are unchanged. - view = query.get("view", ["findings"])[0] - if view not in {"findings", "history"}: - self._send_error("Unknown Explore view.", status=400) - return page = self.server.completed_todo_pages.page( scope=("explore_history" if view == "history" else "explore_results", goal_id), cursor=query.get("cursor", [""])[0], diff --git a/loopx/presentation/runtime_workspace.py b/loopx/presentation/runtime_workspace.py index e9c3bed7f3..b347623a91 100644 --- a/loopx/presentation/runtime_workspace.py +++ b/loopx/presentation/runtime_workspace.py @@ -166,9 +166,17 @@ def do_GET(self): "status_url": self.server.status_url, "read_only_sources": self.server.catalog, "control_plane_write_enabled": False, "reward_write_enabled": False} self._json(status) - elif parsed.path == "/api/chat/explore-results" and query.get("view") == ["history"]: + elif parsed.path == "/api/chat/explore-results": + from .explore_results_api import _finding_rows + cursor = query.get("cursor", [""])[0] - self._json(self.server.reader.history(goal_id, cursor)) + view = query.get("view", ["findings"]) + if view == ["history"]: + self._json(self.server.reader.history(goal_id, cursor)) + elif view == ["findings"]: + self._json(self.server.reader.findings(goal_id, cursor, projector=_finding_rows)) + else: + self._json({"ok": False, "error": "Unknown Explore view."}, 400) elif parsed.path in {RESEARCH_ANALYSIS_PATH, RESEARCH_ANALYSIS_PATH + "/citation"}: if self.server.analysis_execution_enabled: self._research_analysis_read() diff --git a/tests/presentation/test_explore_history.py b/tests/presentation/test_explore_history.py index 08a623e291..1f0fd4ef1f 100644 --- a/tests/presentation/test_explore_history.py +++ b/tests/presentation/test_explore_history.py @@ -41,6 +41,12 @@ def test_history_keeps_updates_in_snapshot_isolates_goal_and_view_and_never_writ {"id": name, "repo": str(tmp_path), "state_file": "active.md"} for name in ("history-fixture", "other-goal") ]})) + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + for goal_id in ("history-fixture", "other-goal"): + initialize_canonical_authority(runtime, goal_id, + build_todo_runtime_shadow_projection(goal_id=goal_id, todos=[], handoff_mode="soft_claim"), + state_path=state, provider="sqlite") log = explore_result_log_path(runtime, "history-fixture") for hour in range(19): append_explore_result_event(log, build_explore_node_event( diff --git a/tests/presentation/test_goal_source.py b/tests/presentation/test_goal_source.py index 2f635a26e9..5992d80798 100644 --- a/tests/presentation/test_goal_source.py +++ b/tests/presentation/test_goal_source.py @@ -105,6 +105,12 @@ def test_real_native_host_lists_goals_and_reads_canonical_history(tmp_path): state_file=f"{goal_id}.md", adapter={"kind": "test_v0", "status": "connected-read-only"}, authority_sources=[])) registry.write_text(json.dumps(dict(schema_version=1, common_runtime_root=str(runtime), goals=goals))) + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + for goal_id in ("first", "second"): + initialize_canonical_authority(runtime, goal_id, + build_todo_runtime_shadow_projection(goal_id=goal_id, todos=[], handoff_mode="soft_claim"), + state_path=project / f"{goal_id}.md", provider="sqlite") cli = Path(sys.executable).with_name("loopx") assert cli.is_file(), "Use the source checkout's prepared Python environment" source = GoalSource.parse({**manifest(scope={"all_registered": True}), "registry": str(registry), diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index b68c243eac..ba7b96aeb4 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -19,13 +19,17 @@ from test_research_analysis_api import workspace as workspace from loopx.capabilities.benchmark_toolkit.trajectory_assessment_store import register_pack from loopx.capabilities.explore.result_log import ( - append_explore_result_event, build_explore_edge_event, build_explore_node_event, + append_explore_result_event, build_explore_edge_event, build_explore_node_event, build_explore_finding_event, explore_history_rows, explore_result_log_path, ) from loopx.control_plane.status.goal_source import GoalSource def native_source(w): + from loopx.control_plane.todos.active_state_todo_parser import parse_todo_source + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + source_root = w['tmp_path'] / 'original' source_root.mkdir() registry, runtime = source_root / 'registry.json', source_root / 'runtime' @@ -37,6 +41,15 @@ def native_source(w): {'id': 'solver-goal', 'repo': str(source_root), 'state_file': 'active.md', 'status': 'active', 'domain': 'source-read', 'authority_sources': [], 'adapter': {'kind': 'test_v0', 'status': 'connected-read-only'}}]})) + rows, _, _ = parse_todo_source(state.read_text()) + for todo in rows['agent']: + todo['schema_version'] = 'todo_item_v0' + initialize_canonical_authority(runtime, 'solver-goal', build_todo_runtime_shadow_projection( + goal_id='solver-goal', todos=rows['agent'], handoff_mode='soft_claim'), state_path=state, provider='sqlite') + for goal in ('research-owner', 'other-owner'): + initialize_canonical_authority(w['tmp_path'] / 'runtime', goal, + build_todo_runtime_shadow_projection(goal_id=goal, todos=[], handoff_mode='soft_claim'), + state_path=w['tmp_path'] / 'active.md', provider='sqlite') log = explore_result_log_path(runtime, 'solver-goal') for name in ('question', 'attempt'): append_explore_result_event(log, build_explore_node_event(goal_id='solver-goal', @@ -221,9 +234,10 @@ def test_original_task_detail_cannot_follow_a_replaced_source(workspace): assert removed.value.code == 409 replacement = copy.deepcopy(raw) replacement['id'] = 'replacement' - state = Path(raw['registry']).parent / 'active.md' - state.write_text(state.read_text().replace('Check the original question against its evidence', - 'Check the replacement question against its own evidence')) + from loopx.todos import update_goal_todo + update_goal_todo(registry_path=Path(raw['registry']), runtime_root_arg=raw['runtime_root'], + goal_id='solver-goal', todo_id='todo_original', + text='Check the replacement question against its own evidence') request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': replacement}) with pytest.raises(HTTPError) as replaced: request(old_url) @@ -278,6 +292,11 @@ def test_removed_parent_does_not_poison_other_local_or_source_goals(workspace): (source_registry.parent / 'other.md').write_bytes((source_registry.parent / 'active.md').read_bytes()) registry['goals'].append({**registry['goals'][0], 'id': 'other-solver', 'state_file': 'other.md'}) source_registry.write_text(json.dumps(registry)) + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + initialize_canonical_authority(Path(raw['runtime_root']), 'other-solver', + build_todo_runtime_shadow_projection(goal_id='other-solver', todos=[], handoff_mode='soft_claim'), + state_path=source_registry.parent / 'other.md', provider='sqlite') other = {**raw, 'id': 'other-source', 'scope': {'goal_id': 'other-solver'}} request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) request('/api/chat/goal-sources', {'parent_goal_id': 'other-owner', 'source': other}) @@ -301,3 +320,87 @@ def test_removed_parent_does_not_poison_other_local_or_source_goals(workspace): 'source_namespace': GoalSource.parse(raw).namespace}) assert len(json.loads(w['server'].registry_path.read_text())['goal_source_links']) == 1 assert source_registry.read_bytes() == before and not w['count'].exists() + + +def test_original_saved_findings_keep_full_text_associations_and_source_fences(workspace): + """Actual source CLI + normal App HTTP; no model or source mutations.""" + from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer + from threading import Thread + + w = workspace + raw, request, _ = native_source(w) + state = Path(raw['registry']).parent / 'active.md' + log = explore_result_log_path(Path(raw['runtime_root']), 'solver-goal') + summary = "Counterexample. " + "Bounded observation. " * 70 + "Retain the original scope." + for index in range(43): + append_explore_result_event(log, build_explore_finding_event(goal_id='solver-goal', + node_id='question', finding_id=f'finding{index}', title=f'Observation {index}', + summary=summary, status='refuted', evidence_refs=['artifact:counterexample'])) + before = {path: path.read_bytes() for path in (state, log, Path(raw['registry']))} + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + namespace = GoalSource.parse(raw).namespace + query = urlencode({'goal_id': 'solver-goal', 'source_namespace': namespace}) + endpoint = '/api/chat/explore-results?' + first = request(endpoint + query + '&view=findings') + assert first['total'] == 43 and len(first['items']) == 40 + item = first['items'][0] + assert item['finding_id'] and 'event_kind' not in item + assert item['summary'] == summary and item['status'] == 'refuted' + assert item['question'] == 'question' and item['scope'] == 'Original native record' + assert item['evidence_refs'] == ['artifact:counterexample'] + assert item['linked_todos'] == [] and item['linked_todos_available'] is False + assert request(endpoint + query)['items'] == first['items'] + cursor_query = urlencode({'cursor': first['next_cursor']}) + assert len(request(endpoint + query + '&view=findings&' + cursor_query)['items']) == 3 + assert request(endpoint + query + '&view=history')['total'] == 46 + for suffix, status in (('&view=history&' + cursor_query, 409), + ('&view=invalid', 400), ('&view=findings&view=history', 400)): + with pytest.raises(HTTPError) as rejected: + request(endpoint + query + suffix) + assert rejected.value.code == status + with pytest.raises(HTTPError) as rejected: + request(endpoint + query, origin='https://other.example') + assert rejected.value.code == 403 + + # Malformed/wrong-Goal evidence fails closed and invalidates previous pages. + events = log.read_text().splitlines() + wrong = json.loads(events[-1]); wrong['goal_id'] = 'unrelated' + log.write_text('\n'.join([*events[:-1], json.dumps(wrong)])) + with pytest.raises(HTTPError) as corrupt: + request(endpoint + query) + assert corrupt.value.code == 409 + assert str(w['tmp_path']) not in corrupt.value.read().decode() + log.write_bytes(before[log]) + with pytest.raises(HTTPError): + request(endpoint + query + '&' + cursor_query) + assert request(endpoint + query)['total'] == 43 + + # The prepared source adapter serves the same finding shape as the real App. + source_server = RuntimeWorkspaceServer(GoalSource.parse(raw), w['tmp_path']) + worker = Thread(target=source_server.serve_forever, daemon=True); worker.start() + try: + with urlopen(f'http://127.0.0.1:{source_server.server_port}' + endpoint + query + '&view=findings', timeout=30) as response: + assert json.load(response)['items'] == first['items'] + finally: + source_server.shutdown(); worker.join(timeout=5); source_server.server_close() + + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', + 'goal_id': 'solver-goal', 'source_namespace': namespace}) + with pytest.raises(HTTPError) as removed: + request(endpoint + query) + assert removed.value.code == 409 + # The source binding cannot change while a read is in flight. + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'source': raw}) + reader = w['server'].goal_source_catalog.resolve('solver-goal')[1] + original = reader.findings + def unlink_during_read(*args, **kwargs): + page = original(*args, **kwargs) + request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', + 'goal_id': 'solver-goal', 'source_namespace': namespace}) + return page + reader.findings = unlink_during_read + with pytest.raises(HTTPError) as replaced: + request(endpoint + query) + assert replaced.value.code == 409 + assert all(path.read_bytes() == data for path, data in before.items()) + assert not w['count'].exists() diff --git a/tests/presentation/test_runtime_workspace.py b/tests/presentation/test_runtime_workspace.py index 99549c26f5..a9771075ee 100644 --- a/tests/presentation/test_runtime_workspace.py +++ b/tests/presentation/test_runtime_workspace.py @@ -131,6 +131,11 @@ def test_invalid_native_history_clears_only_its_source_and_recovers(tmp_path, in id="shared-goal", status="active", domain="source-read", repo=str(project), state_file="goal.md", adapter={"kind": "test_v0", "status": "connected-read-only"}, authority_sources=[])]))) + from tests.control_plane.canonical_authority_fixture import initialize_canonical_authority + from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection + initialize_canonical_authority(runtime, "shared-goal", + build_todo_runtime_shadow_projection(goal_id="shared-goal", todos=[], handoff_mode="soft_claim"), + state_path=project / "goal.md", provider="sqlite") cli = Path(sys.executable).with_name("loopx") assert cli.is_file(), "Use the source checkout's prepared Python environment" source = GoalSource("native", "Native", str(registry), str(runtime), str(cli), @@ -138,7 +143,7 @@ def test_invalid_native_history_clears_only_its_source_and_recovers(tmp_path, in events = [build_explore_node_event(goal_id="shared-goal", node_id=f"question{i}", title=f"Question {i}") for i in range(43)] log = runtime / "goals" / "shared-goal" / "explore-result-log.jsonl" - log.parent.mkdir(parents=True) + log.parent.mkdir(parents=True, exist_ok=True) valid = "\n".join(json.dumps(event) for event in events).encode() log.write_bytes(valid) routes = ("/api/goal-source/history?goal_id=shared-goal", diff --git a/tests/test_chat_explore_results.py b/tests/test_chat_explore_results.py index 5d69b25a52..34fa18bc2c 100644 --- a/tests/test_chat_explore_results.py +++ b/tests/test_chat_explore_results.py @@ -18,8 +18,8 @@ ) -@pytest.mark.parametrize("canonical", [False, True]) -def test_ninth_link_readback_pagination_scope_failure_and_recovery(tmp_path, monkeypatch, canonical): +@pytest.mark.parametrize("provider", ["file", "sqlite"]) +def test_ninth_link_readback_pagination_scope_failure_and_recovery(tmp_path, monkeypatch, provider): registry = tmp_path / "registry.json" runtime = tmp_path / "runtime" (tmp_path / "active.md").write_text("# Synthetic Goal\n") @@ -40,7 +40,6 @@ def test_ninth_link_readback_pagination_scope_failure_and_recovery(tmp_path, mon summary="Counterexample at input revision A " + "c" * 1700 + "; do not transfer beyond the tested scope.", evidence_refs=["artifact:counterexample"], agent_id="worker", )) - from loopx.todos import list_goal_todos state = tmp_path / "active.md" linked = "todo_linked0001" linked_metadata = format_todo_metadata_line( @@ -70,20 +69,21 @@ def test_ninth_link_readback_pagination_scope_failure_and_recovery(tmp_path, mon + " -->\n", encoding="utf-8", ) - if canonical: - from pathlib import Path - monkeypatch.syspath_prepend(str(Path(__file__).parent / "control_plane")) - from canonical_authority_fixture import initialize_canonical_authority, isolate_sqlite_runtime - from loopx.control_plane.coordination.runtime_shadow import build_todo_runtime_shadow_projection - isolate_sqlite_runtime(tmp_path, monkeypatch) - rows = list_goal_todos(registry_path=registry, goal_id="evidence-goal")["todos"] - rows += list_goal_todos(registry_path=registry, goal_id="evidence-goal", role="agent", - status="done", read_scope="completed_history")["todos"] - initialize_canonical_authority(runtime, "evidence-goal", - build_todo_runtime_shadow_projection(goal_id="evidence-goal", todos=rows, handoff_mode="soft_claim"), - state_path=state, provider="sqlite") - # The reader must prefer canonical association over stale presentation. - state.write_text("# Synthetic Goal\n") + from tests.control_plane.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.todos.active_state_todo_parser import parse_todo_source + isolate_sqlite_runtime(tmp_path, monkeypatch) + active, archived, _ = parse_todo_source(state.read_text()) + rows = active['agent'] + archived + for row in rows: + row['schema_version'] = 'todo_item_v0' + for goal in ('evidence-goal', 'other-goal'): + initialize_canonical_authority(runtime, goal, + build_todo_runtime_shadow_projection(goal_id=goal, + todos=rows if goal == 'evidence-goal' else [], handoff_mode='soft_claim'), + state_path=state, provider=provider) + # Canonical associations survive a stale presentation document. + state.write_text("# Synthetic Goal\n") before = log.read_bytes() state_before = state.read_bytes() server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) @@ -127,16 +127,13 @@ def read(url): assert str(tmp_path).encode() not in corrupt.value.read() log.write_bytes(before) assert read(base + "evidence-goal")["total"] == 41 - if not canonical: - # Missing Todo authority must not masquerade as an unlinked finding. - state.unlink() - with pytest.raises(HTTPError) as missing: - read(base + "evidence-goal") - assert missing.value.code == 409 - state.write_bytes(state_before) - assert len(read(base + "evidence-goal")["items"][0]["linked_todos"]) == 2 - state.write_text("# Synthetic Goal\n") - assert read(base + "evidence-goal")["items"][0]["linked_todos"] == [] + state.unlink() + assert len(read(base + "evidence-goal")["items"][0]["linked_todos"]) == 2 + # Missing canonical authority is not an empty, unlinked result. + from loopx.presentation.explore_results_api import _result_rows + from loopx.control_plane.coordination.local_authority import CanonicalAuthorityRequired + with pytest.raises(CanonicalAuthorityRequired): + _result_rows(tmp_path / "missing-runtime", "evidence-goal", registry) finally: server.shutdown() server.server_close()