diff --git a/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts b/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts index 159d761ac1..08b8f79c81 100644 --- a/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts +++ b/apps/presentation/dashboard/smoke/presentation-surface-schema-smoke.ts @@ -331,6 +331,14 @@ async function verifyProjectionFetchContract() { } assert(mismatchRejected, "projection identity mismatch must fail closed"); + let wrongGoalRejected = false; + try { + await fetchPresentationProjection("http://127.0.0.1:8765/extension-projection", { + ...matchingSurface, goal_id: "another-goal", + }); + } catch (error) {wrongGoalRejected = String(error).includes("does not match");} + assert(wrongGoalRejected, "matching revision/hash must not authorize another Goal’s result"); + let publicHostRejected = false; requestedUrl = ""; try { diff --git a/apps/presentation/dashboard/src/data/decision-research.ts b/apps/presentation/dashboard/src/data/decision-research.ts index 2268f59d9b..a11780a5ea 100644 --- a/apps/presentation/dashboard/src/data/decision-research.ts +++ b/apps/presentation/dashboard/src/data/decision-research.ts @@ -415,7 +415,14 @@ export async function fetchPresentationProjection( target.searchParams.set("payload_sha256", surface.detail_ref.payload_sha256); const response = await fetch(target.toString(), { cache: "no-store" }); if (!response.ok) { - throw new Error(`HTTP ${response.status} while loading projection`); + let detail = ""; + try { + const failure: unknown = await response.json(); + if (typeof failure === "object" && failure !== null && "error" in failure && typeof failure.error === "string") { + detail = `: ${failure.error.slice(0, 300)}`; + } + } catch { /* A malformed error response still clears the previous view. */ } + throw new Error(`HTTP ${response.status} while loading projection${detail}`); } const projection = parsePresentationProjection(await response.json()); const expectedIdentity = surface.detail_ref; @@ -426,6 +433,7 @@ export async function fetchPresentationProjection( || projection.payload_sha256 !== expectedIdentity.payload_sha256 || projection.view_schema !== surface.view_schema || projection.surface_kind !== surface.surface_kind + || projection.goal_id !== surface.goal_id ) { throw new Error("projection response does not match the requested detail_ref"); } diff --git a/apps/presentation/dashboard/src/data/local-status-query.ts b/apps/presentation/dashboard/src/data/local-status-query.ts index c8a67da72a..a642f68090 100644 --- a/apps/presentation/dashboard/src/data/local-status-query.ts +++ b/apps/presentation/dashboard/src/data/local-status-query.ts @@ -153,6 +153,13 @@ export async function fetchPeriodicReportIndex(indexUrl: string, goalId: string) return periodicReportIndexResponseSchema.parse(await response.json()).periodic_reports; } +export function presentationApiUrls(payload: StatusPayload, source: ResolvedLocalStatusUrl) { + return { + indexUrl: localApiUrl(source, payload.local_dashboard_api?.presentation_surfaces_url), + detailUrl: localApiUrl(source, payload.local_dashboard_api?.presentation_detail_url), + }; +} + export async function fetchPeriodicReportProjection( detailUrl: string, ref: PeriodicReportDetailRef, diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 92aee77951..6c414cd891 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -139,7 +139,7 @@ li[data-check="blocked"] .goal-team-check-pill { border-color: transparent; back .goal-team-results p { font-size: 12px; line-height: 1.7; color: var(--pw-muted); } .goal-team-results [role="alert"] { color: var(--pw-red, #b42318); } .goal-team-results button, .goal-team-results select { min-height: 44px; padding: 8px 12px; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; background: var(--pw-surface, #fff); color: inherit; font: inherit; cursor: pointer; } -.goal-managed-results > header button { flex-shrink: 0; white-space: nowrap; } +.goal-managed-results > header button, .goal-research-results > header button { flex-shrink: 0; white-space: nowrap; } .goal-team-results button[aria-pressed="true"] { border-color: var(--pw-text); } .goal-team-results button:disabled { opacity: .5; cursor: default; } .goal-team-results summary { cursor: pointer; padding: 12px 0; font-size: 12px; min-height: 44px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx new file mode 100644 index 0000000000..69c29a765f --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-results.tsx @@ -0,0 +1,55 @@ +import {useEffect, useState} from "react"; +import {RefreshCw} from "lucide-react"; +import {fetchPresentationProjection, type DecisionResearchView} from "../../data/decision-research"; +import {parsePresentationSurfaceCollectionResponse, type PresentationSurface, type RunGoal} from "../../data/status"; +import {DecisionResearchSurface} from "../../views/decision-research-surface"; + +export type GoalResearchApi = {indexUrl: string; detailUrl: string; goals: RunGoal[]}; +type Result = {surface: PresentationSurface; view?: DecisionResearchView; error?: string}; + +/** The active Goal's published research; the extension index owns availability. */ +export function GoalResearchResults({goalId, api, zh}: {goalId: string; api: GoalResearchApi; zh: boolean}) { + const [results, setResults] = useState([]); + const [busy, setBusy] = useState(true); + const [error, setError] = useState(""); + const [revision, setRevision] = useState(0); + useEffect(() => { + let current = true; + setResults([]); setError(""); setBusy(true); + void (async () => { + try { + const response = await fetch(api.indexUrl, {cache: "no-store"}); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const collection = parsePresentationSurfaceCollectionResponse(await response.json()); + const surfaces = collection.items.filter(surface => surface.goal_id === goalId + && surface.visibility === "public-safe" + && surface.surface_kind === "decision_research_dashboard" + && surface.view_schema === "decision_research_dashboard_v0"); + const next = await Promise.all(surfaces.map(async (surface): Promise => { + if (surface.state !== "ready" && surface.state !== "review_due") return {surface}; + try { + const projection = await fetchPresentationProjection(api.detailUrl, surface); + return {surface, view: projection.view}; + } catch (failure) {return {surface, error: String(failure)};} + })); + if (current) setResults(next); + } catch (failure) {if (current) setError(String(failure));} + finally {if (current) setBusy(false);} + })(); + return () => {current = false;}; + }, [goalId, api.indexUrl, api.detailUrl, revision]); + + return
+

{zh ? "研究成果" : "Research results"}

+

{zh ? "查看本 Goal 已保存的研究结论、反证和待复核事项。" : "Review this Goal’s saved conclusions, counterevidence and open questions."}

+
+ {busy ?

{zh ? "正在核验研究结果…" : "Verifying research results…"}

: null} + {error ?

{zh ? "无法核验研究结果;旧内容已清除。" : "Cannot verify research results; previous content was cleared."} {error}

: null} + {!busy && !error && !results.length ?

{zh ? "本 Goal 暂无可读取的公开研究成果。" : "No readable public research results for this Goal yet."}

: null} + {results.map(result => ) } +
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 7cd0d09edc..ec41a41d48 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -39,6 +39,7 @@ import { ChannelHeader } from "./channel-header"; import { GoalLoopXMode } from "./goal-loopx-mode"; import { GoalTeamResults } from "./goal-team-results"; import { GoalManagedResults } from "./goal-managed-results"; +import { GoalResearchResults, type GoalResearchApi } from "./goal-research-results"; import { sendLoopXMessage, type LoopXModeSnapshot } from "../../data/chat"; import { MessageActivity } from "./message-activity"; import { ChannelTimeline } from "./channel-timeline"; @@ -202,6 +203,7 @@ function GoalOutputsView({ teamSessionId, goalId, localResults, + researchApi, }: { active: boolean; items: Array>; @@ -210,6 +212,7 @@ function GoalOutputsView({ teamSessionId?: string; goalId: string; localResults: boolean; + researchApi?: GoalResearchApi; }) { const { locale, t } = useWorkspaceI18n(); const [teamSnapshot, setTeamSnapshot] = useState(null); @@ -261,6 +264,7 @@ function GoalOutputsView({ ))} {localResults ? : null} + {active && researchApi ? : null} {active && teamSessionId && !teamSnapshot && !teamError ?

{t("files.checkingTeam")}

: null} {active && teamSessionId && teamError ?

{t("files.teamLoadFailed")}

: null} {active && teamConfigured && teamSessionId ? : null} @@ -787,6 +791,7 @@ export function PersonalWorkspacePage({ managerChannelBinding, managerRuntime, model, + researchApi, readOnly = false, typedActionsRevision = 0, selectedAgentId: controlledAgentId, @@ -807,6 +812,7 @@ export function PersonalWorkspacePage({ managerChannelBinding?: ManagerChannelBinding | null; managerRuntime?: ManagerRuntimeSessionReadback | null; model: WorkspaceModel; + researchApi?: GoalResearchApi; ownerLabel?: string; readOnly?: boolean; // Bumped when typed previews were stored outside this page, so the page @@ -1947,7 +1953,8 @@ export function PersonalWorkspacePage({ reportState={model.periodicReports} teamSessionId={!readOnly && selectedAgentId === "codex" ? conversationSessionId : undefined} goalId={selectedGoal.goalId} - localResults={!readOnly && selectedGoalTab === "files"} + localResults={!readOnly && selectedGoalTab === "files"} + researchApi={researchApi} />), chat: (<> {selectedGoal && activeSessionRun?.goalId === selectedGoal.goalId ? ( diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index 2e8fd7679d..f20c961818 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -33,6 +33,7 @@ import { fetchPeriodicReportIndex, fetchPeriodicReportProjection, periodicReportApiUrls, + presentationApiUrls, resolveLocalStatusUrl, scopedStatusUrl, } from "../data/local-status-query"; @@ -1289,6 +1290,8 @@ function PersonalGoalHome({ }, [payload, rows, progress, goalSubagentConfigurationEnabled, t]); const selectedGoal = model.goals.find((goal) => goal.goalId === selectedGoalId) ?? null; const selectedPayload = progress?.snapshots[selectedGoalId] ?? payload; + const researchSource = resolveLocalStatusUrl(statusSourceControl.activeSource.statusUrl, window.location.href); + const researchUrls = researchSource.source ? presentationApiUrls(selectedPayload, researchSource.source) : null; const [periodicReport, setPeriodicReport] = useState(null); const [periodicReportError, setPeriodicReportError] = useState(null); const [periodicReportLoading, setPeriodicReportLoading] = useState(false); @@ -3071,6 +3074,10 @@ function PersonalGoalHome({ conversationSupportsSteering={steeringSessionIds.has(runtimeBindings[contextId]?.sessionId ?? "")} conversationHistoryState={conversationHistory} model={workspaceModel} + researchApi={researchUrls?.indexUrl && researchUrls.detailUrl ? { + indexUrl: researchUrls.indexUrl, detailUrl: researchUrls.detailUrl, + goals: selectedPayload.run_history.goals, + } : undefined} readOnly={readOnly} selectedAgentId={selectedAgent.agentId} selectedGoalId={selectedGoal?.goalId ?? null} diff --git a/docs/assets/personal-workspace/research-results-desktop.png b/docs/assets/personal-workspace/research-results-desktop.png new file mode 100644 index 0000000000..3c6bcc82aa Binary files /dev/null and b/docs/assets/personal-workspace/research-results-desktop.png differ diff --git a/docs/assets/personal-workspace/research-results-mobile.png b/docs/assets/personal-workspace/research-results-mobile.png new file mode 100644 index 0000000000..2ce9413b3b Binary files /dev/null and b/docs/assets/personal-workspace/research-results-mobile.png differ diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 23066c0266..7d6ededc00 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -40,6 +40,7 @@ import { turnStepsScenario } from "./personal-workspace-browser/turn-steps.mjs"; import { monitorReadbackScenario } from "./personal-workspace-browser/monitor-readback.mjs"; import { teamEvidenceScenario } from "./personal-workspace-browser/team-evidence.mjs"; import { managedGoalResultsScenario } from "./personal-workspace-browser/managed-goal-results.mjs"; +import { researchResultsScenario } from "./personal-workspace-browser/research-results.mjs"; import { loopxModeScenario } from "./personal-workspace-browser/loopx-mode.mjs"; import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; import { stewardJourneyScenario } from "./personal-workspace-browser/steward-journey.mjs"; @@ -78,6 +79,7 @@ scenarioCatalog.push(blockedNoticeSettingsScenario); scenarioCatalog.push(nativeChildActivityScenario); scenarioCatalog.push(externalEvidenceReadbackScenario); scenarioCatalog.push(configurationBackupScenario); +scenarioCatalog.push(researchResultsScenario); const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 7326fa376d..7c8c507b65 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -391,7 +391,7 @@ function filterStatusFixtureToScope(fixture, matchesScope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, notificationProjection = null, progressiveWorkspace = false, runtimeAgents = null, presentationApi = false } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -517,6 +517,10 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true ...(fixture.local_dashboard_api ?? {}), periodic_report_index_url: "/periodic-report-workspace", periodic_report_detail_url: "/periodic-report-workspace-projection", + ...(presentationApi ? { + presentation_surfaces_url: `${typeof presentationApi === "string" ? presentationApi : ""}/extension-presentation-surfaces`, + presentation_detail_url: `${typeof presentationApi === "string" ? presentationApi : ""}/extension-projection`, + } : {}), }; for (const directoryGoal of directoryGoalFixtures) { if (state.deletedGoalIds.has(directoryGoal.id)) continue; diff --git a/examples/personal-workspace-browser/research-results.mjs b/examples/personal-workspace-browser/research-results.mjs new file mode 100644 index 0000000000..e6dd80ae98 --- /dev/null +++ b/examples/personal-workspace-browser/research-results.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import {readFile} from "node:fs/promises"; +import {resolve} from "node:path"; +import {outputDir, dashboardDir} from "./fixture.mjs"; +import {openWorkspacePage} from "./scenario-context.mjs"; + +export const researchResultsScenario = { + id: "research-results", + async run({browser, collectCoverage, url}) { + let fixture = JSON.parse(await readFile(resolve(dashboardDir, "src/data/fixtures/presentation-projection.example.json"))); + fixture.projection.goal_id = "product-release"; + fixture.projection.view.metrics.push({id: "precise-supply", label: "Reported supply", value: "123456789.1234567891 SYN", + detail: "Source height unknown; not a net flow measurement.", tone: "warning"}); + let mode = "ready"; + let reads = 0; + let indexes = 0; + let projection = fixture.projection; + let surface = { + extension_id: projection.extension_id, extension_revision: projection.extension_revision, + surface_id: projection.surface_id, surface_kind: projection.surface_kind, + title: "Investment Research", view_schema: projection.view_schema, visibility: "public-safe", + state: "review_due", goal_id: projection.goal_id, generated_at: projection.generated_at, + review_due_at: projection.review_due_at, diagnostic: null, + empty_state_title: "No validated research yet", empty_state_detail: "Publish a validated projection.", + detail_ref: {extension_id: projection.extension_id, extension_revision: projection.extension_revision, + surface_id: projection.surface_id, payload_sha256: projection.payload_sha256}, + }; + const nativeUrl = process.env.LOOPX_RESEARCH_NATIVE_URL; + if (nativeUrl) { + const collection = await (await fetch(`${nativeUrl}/extension-presentation-surfaces`)).json(); + surface = collection.presentation_surfaces.items.find(item => item.goal_id === "product-release"); + assert(surface, "An explicitly supplied isolated native fixture must publish product-release research"); + fixture = await (await fetch(`${nativeUrl}/extension-projection?${new URLSearchParams(surface.detail_ref)}`)).json(); + projection = fixture.projection; + } + const context = await openWorkspacePage(browser, url, {collectCoverage, apiOptions: {presentationApi: nativeUrl ?? true}, + beforeGoto: async (_, page) => { + await page.route("**/extension-presentation-surfaces", route => { + indexes++; + if (nativeUrl && mode === "ready") return route.continue(); + if (mode === "malformed") return route.fulfill({json: {ok: true}}); + const items = mode === "disabled" ? [] : [{...surface, visibility: mode === "owner-only" ? "owner-only" : "public-safe"}]; + return route.fulfill({json: {ok: true, presentation_surfaces: { + schema_version: "extension_presentation_surfaces_v0", count: items.length, ready_count: 0, + review_due_count: items.length, empty_count: 0, invalid_count: 0, items, + }}}); + }); + await page.route("**/extension-projection?**", route => { + reads++; + const query = new URL(route.request().url()).searchParams; + assert.equal(query.get("payload_sha256"), projection.payload_sha256); + assert.equal(query.get("extension_revision"), projection.extension_revision); + if (nativeUrl && mode === "ready") return route.continue(); + if (mode === "stale") return route.fulfill({status: 409, json: {error: "revision changed"}}); + return route.fulfill({json: {...fixture, projection: {...projection, + goal_id: mode === "wrong-goal" ? "research-monitor" : projection.goal_id}}}); + }); + }, + }); + const {page, api} = context; + try { + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + assert.equal(indexes, 0, "Research must remain lazy outside the results tab"); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "成果", exact: true}).click(); + const results = page.getByRole("region", {name: "研究成果", exact: true}); + await results.getByText("123456789.1234567891 SYN", {exact: true}).waitFor(); + assert.match(await results.textContent(), /Insufficient Evidence/); + assert.match(await results.textContent(), /Counterevidence/); + assert.match(await results.textContent(), /Source height unknown/); + assert.match(await results.textContent(), /2026-01-15/); + assert.match(await results.textContent(), /review_due/); + assert.equal(reads, 1); + assert.equal(await results.getByTestId("research-detail-ref-hash").textContent(), projection.payload_sha256); + await page.screenshot({path: resolve(outputDir, "research-results-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await results.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "research-results-mobile.png"), animations: "disabled"}); + assert(await results.evaluate(el => el.scrollWidth <= el.clientWidth + 1), "Research must fit a phone: " + JSON.stringify(await results.evaluate(el => [...el.querySelectorAll("*")].filter(node => node.scrollWidth > node.clientWidth + 1).slice(0, 6).map(node => ({tag: node.tagName, css: node.className, text: node.textContent.slice(0,80), width: node.clientWidth, scroll: node.scrollWidth}))))); + const refresh = results.getByRole("button", {name: "刷新", exact: true}); + await refresh.focus(); + mode = "wrong-goal"; + await page.keyboard.press("Enter"); + await results.getByText(/does not match the requested detail_ref/).waitFor(); + assert.equal(await results.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); + mode = "stale"; + await refresh.click(); + await results.getByText(/HTTP 409.*revision changed/).waitFor(); + mode = "disabled"; + await refresh.click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + const priorReads = reads; + mode = "owner-only"; + await refresh.click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + assert.equal(reads, priorReads, "Owner-only details must never be fetched"); + mode = "malformed"; + await refresh.click(); + await results.getByRole("alert").waitFor(); + assert.equal(await results.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); + mode = "ready"; + await refresh.click(); + await results.getByText("123456789.1234567891 SYN", {exact: true}).waitFor(); + await page.setViewportSize({width: 1512, height: 982}); + await page.locator(".personal-goal-link", {hasText: "Research Monitor"}).click(); + await page.getByRole("navigation", {name: "Goal 视图"}).getByRole("button", {name: "成果", exact: true}).click(); + await results.getByText("本 Goal 暂无可读取的公开研究成果。", {exact: true}).waitFor(); + assert.equal(await results.getByText("123456789.1234567891 SYN", {exact: true}).count(), 0); + assert.equal(api.turnRequests.length, 0, "Reading research must not invoke a model"); + await page.evaluate(() => localStorage.setItem("loopx-pw-locale", "en")); + await page.reload({waitUntil: "networkidle"}); + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + await page.getByRole("button", {name: "Files", exact: true}).click(); + const english = page.getByRole("region", {name: "Research results", exact: true}); + await english.getByText("123456789.1234567891 SYN", {exact: true}).waitFor(); + assert.match(await english.textContent(), /counterevidence and open questions/); + return {note: `Packaged results consume Goal-scoped public research, preserve uncertainty, and clear stale/disabled/wrong-Goal contents; ${nativeUrl ? "real isolated Core publication/HTTP, scripted Goal directory and corruption cases" : "scripted API fixture"}`, + projectionSha256: projection.payload_sha256, coverageEntries: await context.close()}; + } finally {if (!page.isClosed()) await context.close();} + }, +}; diff --git a/loopx/chat_server.py b/loopx/chat_server.py index 29e57f66cb..f3989ef116 100644 --- a/loopx/chat_server.py +++ b/loopx/chat_server.py @@ -91,6 +91,8 @@ from .registry import registry_goals, resolve_state_file from .state_projection import build_active_state_structured_projection from .status_server import ( + DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, + DEFAULT_EXTENSION_PROJECTION_PATH, cors_response_headers, is_loopback_host, is_loopback_origin, @@ -1340,6 +1342,10 @@ def _action_apply(self, proposal_id: str) -> None: def do_GET(self) -> None: path = urlparse(self.path).path + if path == DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH: + return self._handle_extension_presentation_surfaces() + if path == DEFAULT_EXTENSION_PROJECTION_PATH: + return self._handle_extension_projection(parse_qs(urlparse(self.path).query)) if path == "/healthz": self._send_json({"ok": True}) return diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index d0cb7b0106..3cbb1e824b 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -33,7 +33,12 @@ from .history import load_registry from .registry import registry_goals from .status import collect_status -from .status_server import parse_goal_activation_filter +from .status_server import ( + DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, + DEFAULT_EXTENSION_PROJECTION_PATH, + ExtensionPresentationRequestMixin, + parse_goal_activation_filter, +) from .todos import list_goal_todos @@ -98,7 +103,7 @@ def _status_access_denied(error: BaseException) -> bool: return False -class ChatStatusRequestMixin: +class ChatStatusRequestMixin(ExtensionPresentationRequestMixin): """Serve the full dashboard status contract through the Chat origin.""" server: Any @@ -182,6 +187,13 @@ def _status(self, *, delivery_review: bool = False) -> None: "registry_revision" ] if not delivery_review: + projection["local_dashboard_api"] = { + **(projection.get("local_dashboard_api") or {}), + "source": "chat", + "status_url": "/status.json", + "presentation_surfaces_url": DEFAULT_EXTENSION_PRESENTATION_SURFACES_PATH, + "presentation_detail_url": DEFAULT_EXTENSION_PROJECTION_PATH, + } attach_host_thread_activity( projection, observers=codex_thread_observers() ) diff --git a/loopx/semantics/project_registry_io_manifest_v1.json b/loopx/semantics/project_registry_io_manifest_v1.json index 94f98cb627..63cf79a797 100644 --- a/loopx/semantics/project_registry_io_manifest_v1.json +++ b/loopx/semantics/project_registry_io_manifest_v1.json @@ -431,7 +431,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._goal_channel_extension_ready::codec_read:load_registry#1", - "line": 978, + "line": 980, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -439,7 +439,7 @@ }, { "site": "loopx/chat_server.py::.ChatRequestHandler._registry_and_goal::codec_read:load_registry#1", - "line": 518, + "line": 520, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -447,7 +447,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat::codec_read:load_registry#1", - "line": 1525, + "line": 1531, "column": 16, "kind": "codec_read", "api": "load_registry", @@ -455,7 +455,7 @@ }, { "site": "loopx/chat_server.py::.serve_chat._wake_goal_context::codec_read:load_registry#1", - "line": 1613, + "line": 1619, "column": 20, "kind": "codec_read", "api": "load_registry", @@ -463,7 +463,7 @@ }, { "site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#1", - "line": 147, + "line": 152, "column": 28, "kind": "codec_read", "api": "load_registry", @@ -471,7 +471,7 @@ }, { "site": "loopx/chat_status_api.py::.ChatStatusRequestMixin._status::codec_read:load_registry#2", - "line": 174, + "line": 179, "column": 21, "kind": "codec_read", "api": "load_registry", @@ -2134,16 +2134,16 @@ "classification": "codec_api" }, { - "site": "loopx/status_server.py::.StatusRequestHandler._handle_extension_presentation_surfaces::codec_read:load_registry#1", - "line": 854, + "site": "loopx/status_server.py::.ExtensionPresentationRequestMixin._handle_extension_presentation_surfaces::codec_read:load_registry#1", + "line": 337, "column": 24, "kind": "codec_read", "api": "load_registry", "classification": "codec_api" }, { - "site": "loopx/status_server.py::.StatusRequestHandler._handle_extension_projection::codec_read:load_registry#1", - "line": 802, + "site": "loopx/status_server.py::.ExtensionPresentationRequestMixin._handle_extension_projection::codec_read:load_registry#1", + "line": 285, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2151,7 +2151,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_periodic_report_index::codec_read:load_registry#1", - "line": 884, + "line": 893, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2159,7 +2159,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_periodic_report_projection::codec_read:load_registry#1", - "line": 948, + "line": 957, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2167,7 +2167,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._handle_review_material::codec_read:load_registry#1", - "line": 746, + "line": 863, "column": 24, "kind": "codec_read", "api": "load_registry", @@ -2175,7 +2175,7 @@ }, { "site": "loopx/status_server.py::.StatusRequestHandler._status_readiness::codec_read:load_registry#1", - "line": 1016, + "line": 1025, "column": 24, "kind": "codec_read", "api": "load_registry", diff --git a/loopx/status_server.py b/loopx/status_server.py index 9171d5c954..de7cf5c7f2 100644 --- a/loopx/status_server.py +++ b/loopx/status_server.py @@ -238,7 +238,124 @@ class StatusHTTPServer(ThreadingHTTPServer): verbose: bool -class StatusRequestHandler(BaseHTTPRequestHandler): +class ExtensionPresentationRequestMixin: + """Shared read-only extension routes for loopback status and Chat surfaces.""" + + server: Any + + def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: + raise NotImplementedError + + def _handle_extension_projection(self, query: dict[str, list[str]]) -> None: + if not is_loopback_host(str(self.server.server_address[0])): + self._send_json( + { + "ok": False, + "error": "extension projection reads require a loopback status server", + }, + status=403, + ) + return + if not is_loopback_origin(self.headers.get("Origin")): + self._send_json( + { + "ok": False, + "error": "extension projection reads only accept loopback browser origins", + }, + status=403, + ) + return + extension_id = (query.get("extension_id") or [""])[0].strip() + surface_id = (query.get("surface_id") or [""])[0].strip() + extension_revision = (query.get("extension_revision") or [""])[0].strip() + payload_sha256 = (query.get("payload_sha256") or [""])[0].strip() + if not (extension_id and surface_id and extension_revision and payload_sha256): + self._send_json( + { + "ok": False, + "error": ( + "extension_id, surface_id, extension_revision, and " + "payload_sha256 are required" + ), + }, + status=400, + ) + return + try: + registry = load_registry(self.server.registry_path) + runtime_root = resolve_runtime_root( + registry, + self.server.runtime_root_override, + registry_path=self.server.registry_path, + ) + envelope = read_extension_projection( + state_file=default_extension_state_file(runtime_root), + extension_id=extension_id, + surface_id=surface_id, + extension_revision=extension_revision, + payload_sha256=payload_sha256, + ) + except Exception as exc: # noqa: BLE001 - local UI needs the read failure. + self._send_json( + { + "ok": False, + "extension_id": extension_id, + "surface_id": surface_id, + "error": str(exc), + }, + status=400, + ) + return + self._send_json({"ok": True, "projection": envelope}) + + def _handle_extension_presentation_surfaces(self) -> None: + if not is_loopback_host(str(self.server.server_address[0])): + self._send_json( + { + "ok": False, + "error": ( + "extension presentation surfaces require a loopback " + "status server" + ), + }, + status=403, + ) + return + if not is_loopback_origin(self.headers.get("Origin")): + self._send_json( + { + "ok": False, + "error": ( + "extension presentation surfaces only accept loopback " + "browser origins" + ), + }, + status=403, + ) + return + try: + registry = load_registry(self.server.registry_path) + runtime_root = resolve_runtime_root( + registry, + self.server.runtime_root_override, + registry_path=self.server.registry_path, + ) + surfaces = collect_active_extension_presentation_surfaces( + state_file=default_extension_state_file(runtime_root), + ) + except Exception as exc: # noqa: BLE001 - local UI needs the read failure. + self._send_json( + { + "ok": False, + "error": str(exc), + }, + status=400, + ) + return + self._send_json({"ok": True, "presentation_surfaces": surfaces}) + + +class StatusRequestHandler(ExtensionPresentationRequestMixin, BaseHTTPRequestHandler): server: StatusHTTPServer def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: @@ -763,114 +880,6 @@ def _handle_review_material(self, query: dict[str, list[str]]) -> None: return self._send_json(payload) - def _handle_extension_projection(self, query: dict[str, list[str]]) -> None: - if not is_loopback_host(str(self.server.server_address[0])): - self._send_json( - { - "ok": False, - "error": "extension projection reads require a loopback status server", - }, - status=403, - ) - return - if not is_loopback_origin(self.headers.get("Origin")): - self._send_json( - { - "ok": False, - "error": "extension projection reads only accept loopback browser origins", - }, - status=403, - ) - return - extension_id = (query.get("extension_id") or [""])[0].strip() - surface_id = (query.get("surface_id") or [""])[0].strip() - extension_revision = (query.get("extension_revision") or [""])[0].strip() - payload_sha256 = (query.get("payload_sha256") or [""])[0].strip() - if not (extension_id and surface_id and extension_revision and payload_sha256): - self._send_json( - { - "ok": False, - "error": ( - "extension_id, surface_id, extension_revision, and " - "payload_sha256 are required" - ), - }, - status=400, - ) - return - try: - registry = load_registry(self.server.registry_path) - runtime_root = resolve_runtime_root( - registry, - self.server.runtime_root_override, - registry_path=self.server.registry_path, - ) - envelope = read_extension_projection( - state_file=default_extension_state_file(runtime_root), - extension_id=extension_id, - surface_id=surface_id, - extension_revision=extension_revision, - payload_sha256=payload_sha256, - ) - except Exception as exc: # noqa: BLE001 - local UI needs the read failure. - self._send_json( - { - "ok": False, - "extension_id": extension_id, - "surface_id": surface_id, - "error": str(exc), - }, - status=400, - ) - return - self._send_json({"ok": True, "projection": envelope}) - - def _handle_extension_presentation_surfaces(self) -> None: - if not is_loopback_host(str(self.server.server_address[0])): - self._send_json( - { - "ok": False, - "error": ( - "extension presentation surfaces require a loopback " - "status server" - ), - }, - status=403, - ) - return - if not is_loopback_origin(self.headers.get("Origin")): - self._send_json( - { - "ok": False, - "error": ( - "extension presentation surfaces only accept loopback " - "browser origins" - ), - }, - status=403, - ) - return - try: - registry = load_registry(self.server.registry_path) - runtime_root = resolve_runtime_root( - registry, - self.server.runtime_root_override, - registry_path=self.server.registry_path, - ) - surfaces = collect_active_extension_presentation_surfaces( - state_file=default_extension_state_file(runtime_root), - ) - except Exception as exc: # noqa: BLE001 - local UI needs the read failure. - self._send_json( - { - "ok": False, - "error": str(exc), - }, - status=400, - ) - return - self._send_json({"ok": True, "presentation_surfaces": surfaces}) - def _handle_periodic_report_index(self, query: dict[str, list[str]]) -> None: if not is_loopback_host( str(self.server.server_address[0]) diff --git a/packages/loopx-finance-value-discovery/README.md b/packages/loopx-finance-value-discovery/README.md index a8b8a48caf..25be7e56b9 100644 --- a/packages/loopx-finance-value-discovery/README.md +++ b/packages/loopx-finance-value-discovery/README.md @@ -243,6 +243,30 @@ a separate readiness rule. 中文:该命令只生成卡片、不发消息; `loopx extension disable loopx-finance-value-discovery --execute`;若只回退 期次指标,删除可选字段并重新发布旧 view 即可。 +The Goal's **Files & Outputs** tab loads the published research only when +opened. Chat and `serve-status` expose the same extension index and exact +revision/hash readback. Only a public-safe view belonging to that Goal is +rendered; refresh clears previous content when the read fails or the extension +is disabled. Research availability does not certify an economic conclusion. + +`render-lark-card` includes the canonical adjudication, original evidence +clocks, exact metric strings, counterevidence, event review conditions and +artifact references, even when no source-period metrics exist. It rejects a +research card exceeding 18,000 UTF-8 bytes rather than silently cutting its +review basis. The existing source-period-only Python renderer remains available. +For an exact published receipt, render its Core-validated `view` with +`build_decision_research_lark_card`; rebuilding an input is not publication +readback, and preparing a card is not sending or adopting a method. + +中文:Goal 的“成果”页按需读取已发布的研究,绑定当前 Goal、扩展版本和内容摘要; +读取失败或停用时清除旧内容。通知卡保留研究结论、反证、未知、原始时点和下一步, +没有期次指标也可显示;超过容量时拒绝裁切。卡片准备、外部送达和方法采用分别验收。 + +Public synthetic examples: [desktop results](../../docs/assets/personal-workspace/research-results-desktop.png) +and [mobile results](../../docs/assets/personal-workspace/research-results-mobile.png). +The expired review warning is intentional; these screens do not represent a +live financial account or an externally delivered notification. + ## Worked Method: How PayPal Surfaced The historical PayPal exercise started with a fresh de-beta scout, not a diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py index 93603389d7..50423ff587 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py @@ -154,7 +154,7 @@ def _direct_parser() -> argparse.ArgumentParser: sub.add_parser("list-packs", help="List bundled industry metric packs.") lark_parser = sub.add_parser( "render-lark-card", - help="Render source-period evidence from the canonical dashboard view.", + help="Render research conclusions and evidence from the canonical dashboard view; no send.", ) lark_parser.add_argument( "--input-json", @@ -244,12 +244,12 @@ def run(argv: Sequence[str] | None = None) -> int: packet = list_finance_metric_packs() elif args.command == "render-lark-card": from .dashboard import build_finance_research_dashboard_packet - from .lark_projection import build_source_period_metrics_lark_card + from .lark_projection import build_decision_research_lark_card dashboard = build_finance_research_dashboard_packet( _load_json(args.input_json) ) - packet = build_source_period_metrics_lark_card( + packet = build_decision_research_lark_card( dashboard["presentation_projection"]["view"] ) else: diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py index 99a4afd9b9..fc541c1e8c 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/lark_projection.py @@ -1,4 +1,4 @@ -"""Lark card projection for the finance-owned source-period view. +"""Lark card projection for the finance-owned research view. The renderer consumes the same validated ``decision_research_dashboard_v0`` view as the Dashboard. It does not read provider payloads or define a second @@ -32,8 +32,6 @@ def _display_value(metric: Mapping[str, Any]) -> str: value = metric.get("value") if value is None: return "missing (not zero)" - if isinstance(value, float): - return f"{value:g} {metric['unit']}" return f"{value} {metric['unit']}" @@ -105,7 +103,7 @@ def render_source_period_metrics_markdown(view: Mapping[str, Any]) -> str: price = ( "missing (not zero)" if market["mark_price"] is None - else f"{market['mark_price']:g}" + else str(market["mark_price"]) ) lines.extend( [ @@ -129,3 +127,89 @@ def build_source_period_metrics_lark_card( template="yellow", footer="LoopX finance projection · evidence only · never auto-ready", ) + + +def render_decision_research_markdown(view: Mapping[str, Any]) -> str: + """Preserve the canonical research, including uncertainty and counterevidence.""" + + validated = validate_decision_research_view(view) + identity = validated["identity"] + decision = validated["adjudication"] + plain = _lark_plain_text + lines = [ + f"**{plain(identity['title'])}**", + plain(identity["subtitle"]), + f"As of: {plain(identity['as_of'])} · evidence cutoff: {plain(identity['evidence_cutoff'])}", + "Research aid only · investment advice: false · trading allowed: false.", + "", + f"**{plain(decision['label'])}** · confidence: {plain(decision['confidence'])}", + plain(decision["summary"]), + ] + + def section(title: str, items: list[str]) -> None: + if items: + lines.extend(["", f"**{title}**", *[f"- {item}" for item in items]]) + + section("Measures", [ + f"{plain(item['label'])}: {plain(item['value'])} · {plain(item['detail'])}" + for item in validated["metrics"] + ]) + for layer in validated["layers"]: + section(plain(layer["label"]), [ + f"{plain(layer['status'])} · {plain(layer['summary'])}", + *map(plain, layer["evidence_points"]), + ]) + for entity in validated["entities"]: + section(plain(entity["display_name"]), [ + f"{plain(entity['symbol'])} · {plain(entity['status'])} · confidence: {plain(entity['confidence'])}", + plain(entity["inference"]), + ]) + section("Observations", [ + f"{plain(item['label'])}: {plain(item['value'])} · {plain(item['kind'])} · {plain(item['as_of'])} · {plain(item['source_ref'])} · invalidation: {plain(item['invalidation'])}" + for item in entity["observations"] + ]) + section("Scenario estimates", [ + f"{plain(item['label'])}: {plain(item['value'])} · {plain(item['horizon'])} · probability: {plain(item['probability'])} · assumptions: {', '.join(map(plain, item['assumptions']))}" + for item in entity["scenario_estimates"] + ]) + section("Counterevidence", list(map(plain, entity["counterevidence"]))) + section("Thesis breakers", list(map(plain, entity["thesis_breakers"]))) + section("Next events", list(map(plain, entity["next_events"]))) + for case in validated["research_ledger"]: + section(plain(case["label"]), [ + f"{plain(case['decision'])} · {plain(case['summary'])}", + *[f"{plain(gate['label'])}: {plain(gate['status'])} · {plain(gate['summary'])}" for gate in case["gate_states"]], + "Evidence: " + ", ".join(map(plain, case["evidence_refs"])), + ]) + for event in validated["event_gates"]: + section(plain(event["label"]), [ + f"{plain(event['status'])} · {plain(event['observation_window'])}", + plain(event["frozen_hypothesis"]), + "Next review: " + plain(event["next_review"]), + ]) + for label, key in [("Observables", "observables"), ("Current evidence", "current_evidence"), + ("Supports", "supports"), ("Refutes", "refutes"), ("Thesis breakers", "thesis_breakers")]: + section(label, list(map(plain, event[key]))) + section("Artifacts", [ + f"{plain(item['label'])}: {plain(item['summary'])} · {plain(item['artifact_ref'])} · evidence: {', '.join(map(plain, item['evidence_refs']))}" + for item in validated["artifacts"] + ]) + method = validated["method_state"] + section("Method", [f"{plain(method['revision'])} · {plain(method['lifecycle_state'])} · active method changed: {str(method['active_method_changed']).lower()} · {plain(method['summary'])}"]) + lines.extend(["", render_source_period_metrics_markdown(validated)]) + return "\n".join(lines) + + +def build_decision_research_lark_card(view: Mapping[str, Any]) -> dict[str, Any]: + """Prepare a complete bounded card; sending requires the existing sink authority.""" + + markdown = render_decision_research_markdown(view) + if len(markdown.encode("utf-8")) > 18_000: + raise ValueError("Research exceeds card capacity; review the complete published result in App. No research was truncated or sent.") + return build_lark_markdown_reply_card( + markdown, + title="Finance research review", + template="yellow", + footer="Research aid only · no trading authority · method adoption is separate", + max_markdown_chars=len(markdown), + ) diff --git a/tests/extensions/test_finance_presentation_view.py b/tests/extensions/test_finance_presentation_view.py index da27b289ff..0cdde1a802 100644 --- a/tests/extensions/test_finance_presentation_view.py +++ b/tests/extensions/test_finance_presentation_view.py @@ -15,6 +15,7 @@ validate_decision_research_view, ) from loopx_finance_value_discovery.lark_projection import ( # noqa: E402 + build_decision_research_lark_card, build_source_period_metrics_lark_card, ) @@ -334,6 +335,56 @@ def test_lark_card_calls_missing_period_evidence_missing_not_zero() -> None: assert "missing (not zero)" in markdown +def test_research_card_without_period_metrics_keeps_review_basis() -> None: + view = _valid_view() + view["metrics"].append({ + "id": "supply", "label": "Reported supply", "value": "123456789.1234567891 SYN", + "detail": "No source as-of height was provided; not a flow measurement.", "tone": "warning", + }) + frozen = deepcopy(view) + card = build_decision_research_lark_card(view) + markdown = card["elements"][0]["text"]["content"] + assert "Insufficient Evidence" in markdown + assert "2026-01-15T12:00:00+00:00" in markdown + assert "123456789.1234567891 SYN" in markdown + assert "not a flow measurement" in markdown + assert "Counterevidence" in markdown and "Capital intensity" in markdown + assert "Refutes" in markdown and "Higher capital expenditure" in markdown + assert "Next review: After the official filing" in markdown + assert "artifact:synthetic-research-packet" in markdown + assert "active method changed: false" in markdown + assert "trading allowed: false" in markdown + assert "No source-period metrics" in markdown + assert "truncated" not in markdown + assert view == frozen + + +def test_research_card_does_not_silently_cut_counterevidence_for_capacity() -> None: + view = _valid_view() + original = view["entities"][0] + view["entities"] = [] + for index in range(3): + entity = deepcopy(original) + entity["entity_id"] = f"synthetic-{index}" + entity["counterevidence"] = ["Unknown source boundary " * 24] * 12 + view["entities"].append(entity) + validate_decision_research_view(view) + with pytest.raises(ValueError, match="exceeds card capacity"): + build_decision_research_lark_card(view) + + +def test_period_card_keeps_source_numeric_precision() -> None: + view = _valid_view() + metric = _source_period_metric() + metric["value"] = 123456789.123456 + view["source_period_metrics"] = [metric] + view["spot_market_identity"] = _spot_market_identity() + view["spot_market_identity"]["contexts"][0]["mark_price"] = 12.123456789 + card = build_decision_research_lark_card(view) + assert "123456789.123456 USD" in card["elements"][0]["text"]["content"] + assert "12.123456789" in card["elements"][0]["text"]["content"] + + def test_lark_card_escapes_dynamic_markdown_without_changing_the_view() -> None: view = _valid_view() metric = _source_period_metric() diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 1663cb1842..beb65cf77d 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -267,7 +267,11 @@ def fake_collect_status(**kwargs): payload = json.loads(response.read().decode("utf-8")) assert response.status == 200 + api = payload.pop("local_dashboard_api") assert payload == {"ok": True, "scope": "active"} + assert api["source"] == "chat" + assert api["presentation_surfaces_url"] == "/extension-presentation-surfaces" + assert api["presentation_detail_url"] == "/extension-projection" assert calls[0]["activation_state_filter"] == "active" assert calls[0]["include_goal_subagent_configuration"] is True finally: diff --git a/tests/test_status_server_extension_projection.py b/tests/test_status_server_extension_projection.py index 7d206c62e4..0f36adbd52 100644 --- a/tests/test_status_server_extension_projection.py +++ b/tests/test_status_server_extension_projection.py @@ -10,6 +10,10 @@ import urllib.parse import urllib.request +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + from loopx.extensions.presentation import publish_extension_projection from loopx.extensions.runtime import default_extension_state_file, install_extension from loopx.status_server import ( @@ -119,6 +123,7 @@ def _published_projection_server( tmp_path: Path, *, visibility: str = "public-safe", + chat: bool = False, ) -> Iterator[tuple[str, dict[str, object], dict[str, object]]]: runtime_root = tmp_path / "runtime" registry = tmp_path / "registry.json" @@ -144,9 +149,12 @@ def _published_projection_server( execute=True, ) - server = StatusHTTPServer(("127.0.0.1", 0), StatusRequestHandler) + server = (ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) if chat + else StatusHTTPServer(("127.0.0.1", 0), StatusRequestHandler)) server.registry_path = registry server.runtime_root_override = None + server.runtime_root = runtime_root + server.selected_goal_id = None server.scan_roots = [tmp_path] server.limit = 5 server.status_path = DEFAULT_STATUS_PATH @@ -168,11 +176,15 @@ def _published_projection_server( server.server_close() +@pytest.mark.parametrize("chat", [False, True]) def test_status_server_advertises_and_serves_projection_by_exact_ref( tmp_path: Path, + chat: bool, ) -> None: - with _published_projection_server(tmp_path) as (base_url, installed, receipt): - status, capabilities = _request_json(f"{base_url}/") + with _published_projection_server(tmp_path, chat=chat) as (base_url, installed, receipt): + status, capabilities = _request_json(f"{base_url}/status.json" if chat else f"{base_url}/") + if chat: + capabilities = capabilities["local_dashboard_api"] assert status == 200 assert capabilities["presentation_surfaces_url"] == ( @@ -217,10 +229,12 @@ def test_status_server_advertises_and_serves_projection_by_exact_ref( } +@pytest.mark.parametrize("chat", [False, True]) def test_status_server_projection_route_rejects_non_loopback_origin( tmp_path: Path, + chat: bool, ) -> None: - with _published_projection_server(tmp_path) as (base_url, installed, receipt): + with _published_projection_server(tmp_path, chat=chat) as (base_url, installed, receipt): query = urllib.parse.urlencode( { "extension_id": "test-research-extension", @@ -241,12 +255,15 @@ def test_status_server_projection_route_rejects_non_loopback_origin( } +@pytest.mark.parametrize("chat", [False, True]) def test_status_server_projection_route_rejects_owner_only_surface( tmp_path: Path, + chat: bool, ) -> None: with _published_projection_server( tmp_path, visibility="owner-only", + chat=chat, ) as (base_url, installed, receipt): query = urllib.parse.urlencode( { @@ -268,10 +285,12 @@ def test_status_server_projection_route_rejects_owner_only_surface( ) +@pytest.mark.parametrize("chat", [False, True]) def test_status_server_projection_route_requires_complete_detail_ref( tmp_path: Path, + chat: bool, ) -> None: - with _published_projection_server(tmp_path) as (base_url, _installed, _receipt): + with _published_projection_server(tmp_path, chat=chat) as (base_url, _installed, _receipt): status, payload = _request_json( f"{base_url}{DEFAULT_EXTENSION_PROJECTION_PATH}?extension_id=test-research-extension", origin="http://localhost:5173",