diff --git a/apps/agentacct/Sources/agentacct/DashboardPane.swift b/apps/agentacct/Sources/agentacct/DashboardPane.swift index 8633ed9..a1896b5 100644 --- a/apps/agentacct/Sources/agentacct/DashboardPane.swift +++ b/apps/agentacct/Sources/agentacct/DashboardPane.swift @@ -106,6 +106,51 @@ struct DashboardWorkItem: Identifiable { } } +/// Complete when the daemon supplies its all-store attention aggregate, or +/// when an older daemon explicitly says the fallback task list is untruncated. +/// A truncated legacy list can show known review items but can never prove the +/// absence of older ones. +struct DashboardAttentionPresentation { + let items: [DashboardWorkItem] + let totalCount: Int? + let isComplete: Bool + let isTruncated: Bool + let isUnavailable: Bool + + init( + recentTasks: [ReceiptSummary], + recentTasksTruncated: Bool?, + attention: ReceiptAttentionPayload?, + fetchError: String? = nil + ) { + if fetchError != nil { + items = [] + totalCount = nil + isComplete = false + isTruncated = false + isUnavailable = true + return + } + + if let attention { + items = attention.tasks.map(DashboardWorkItem.init) + totalCount = attention.total + isComplete = true + isTruncated = attention.truncated || attention.total > attention.tasks.count + isUnavailable = false + return + } + + let recentItems = recentTasks.map(DashboardWorkItem.init) + items = recentItems.filter { $0.needsReview && $0.hasFinding } + + recentItems.filter { $0.needsReview && !$0.hasFinding } + isComplete = recentTasksTruncated == false + totalCount = isComplete ? items.count : nil + isTruncated = !isComplete + isUnavailable = false + } +} + enum DashboardUsageSeries: String, CaseIterable, Identifiable { case tokens = "Tokens" case cost = "Cost" @@ -214,12 +259,13 @@ struct DashboardPane: View { } } - private var attentionItems: [DashboardWorkItem] { - let items = dashboard.receiptTasks.map(DashboardWorkItem.init) - // Failed evidence is the more urgent review target. Preserve API order - // inside each group so equal-priority tasks remain stable. - return items.filter { $0.needsReview && $0.hasFinding } - + items.filter { $0.needsReview && !$0.hasFinding } + private var attention: DashboardAttentionPresentation { + DashboardAttentionPresentation( + recentTasks: dashboard.receiptTasks, + recentTasksTruncated: dashboard.receiptTasksTruncated, + attention: dashboard.receiptAttention, + fetchError: dashboard.receiptListError + ) } var body: some View { @@ -233,7 +279,7 @@ struct DashboardPane: View { selection.open(destination) } } right: { - NeedsReviewCard(items: attentionItems) { destination in + NeedsReviewCard(attention: attention) { destination in selection.open(destination) } } @@ -504,33 +550,49 @@ private struct RecentWorkRow: View { } private struct NeedsReviewCard: View { - let items: [DashboardWorkItem] + let attention: DashboardAttentionPresentation let open: (DashboardDestination) -> Void - private var visibleItems: [DashboardWorkItem] { Array(items.prefix(2)) } + private var visibleItems: [DashboardWorkItem] { Array(attention.items.prefix(2)) } var body: some View { Card(padding: 0, fillsHeight: true) { VStack(spacing: 0) { - DashboardCardHeader("Needs review", count: items.count) { - if items.count > visibleItems.count { + DashboardCardHeader("Needs review", count: attention.totalCount) { + if attention.isTruncated { Button { open(.work) } label: { - Text("View all").font(Type.captionSemibold) + Text("Open Work").font(Type.captionSemibold) } .foregroundStyle(Theme.accent) .buttonStyle(QuietButtonStyle()) - .accessibilityIdentifier("dashboard.review.view-all") + .accessibilityIdentifier("dashboard.review.open-work") } } Divider().overlay(Theme.hairline) if visibleItems.isEmpty { - DashboardEmptyState( - icon: "checkmark.circle.fill", - title: "All clear", - message: "No blocked work or failed checks." - ) - .frame(minHeight: 222) + if attention.isUnavailable { + DashboardEmptyState( + icon: "wifi.exclamationmark", + title: "Review status unavailable", + message: "The latest receipt refresh failed. Cached results aren't shown as current." + ) + .frame(minHeight: 222) + } else if attention.isComplete { + DashboardEmptyState( + icon: "checkmark.circle.fill", + title: "All clear", + message: "No blocked work or failed checks." + ) + .frame(minHeight: 222) + } else { + DashboardEmptyState( + icon: "exclamationmark.triangle.fill", + title: "Review status incomplete", + message: "Older work may be missing from this summary." + ) + .frame(minHeight: 222) + } } else { ForEach(Array(visibleItems.enumerated()), id: \.element.id) { index, item in DashboardAttentionRow(item: item) { open(.task(item.id)) } diff --git a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift index 23a2190..a5c8f32 100644 --- a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift +++ b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift @@ -124,6 +124,7 @@ struct DashboardSnapshotConfiguration { let width: CGFloat let height: CGFloat let colorScheme: ColorScheme + let workState: SnapshotWorkStoreState var filename: String { let appearance = colorScheme == .dark ? "dark" : "light" @@ -131,13 +132,15 @@ struct DashboardSnapshotConfiguration { } static let reviewConfigurations: [Self] = [ - Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light), - Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark), + Self(viewport: "minimum", width: 960, height: 560, colorScheme: .light, workState: .populated), + Self(viewport: "minimum", width: 960, height: 560, colorScheme: .dark, workState: .populated), // The reference viewport must show the complete dashboard, including // chart labels. The shorter minimum pair intentionally verifies the // real top-of-scroll experience instead. - Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light), - Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark), + Self(viewport: "reference", width: 1120, height: 800, colorScheme: .light, workState: .populated), + Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark, workState: .populated), + Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .light, workState: .listErrorWithRetainedData), + Self(viewport: "attention-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .listErrorWithRetainedData), ] } @@ -178,13 +181,15 @@ enum DashboardSnapshotRenderer { SnapshotScheme.override = nil } - let glance = GlanceState(preloaded: fixture.glanceSnapshot) - let dashboard = DashboardStore(preloaded: fixture) - let selection = AppSelection() - selection.pane = .dashboard - return try configurations.map { configuration in SnapshotScheme.override = configuration.colorScheme + let glance = GlanceState(preloaded: fixture.glanceSnapshot) + let dashboard = DashboardStore( + preloaded: fixture, + workState: configuration.workState + ) + let selection = AppSelection() + selection.pane = .dashboard // A packaged app consistently offers setup here. Injecting that // state keeps SwiftPM and packaged-build snapshots identical. let view = MainWindow(canSetUpOverride: true) diff --git a/apps/agentacct/Sources/agentacct/DashboardStore.swift b/apps/agentacct/Sources/agentacct/DashboardStore.swift index 0619434..b7e6e7e 100644 --- a/apps/agentacct/Sources/agentacct/DashboardStore.swift +++ b/apps/agentacct/Sources/agentacct/DashboardStore.swift @@ -8,6 +8,7 @@ enum SnapshotWorkStoreState { case populated case empty case listError + case listErrorWithRetainedData case receiptLoading case receiptError } @@ -24,6 +25,8 @@ final class DashboardStore: ObservableObject { @Published private(set) var usage: UsageSummary? @Published private(set) var receiptTasks: [ReceiptSummary] = [] @Published private(set) var totalReceiptTasks: Int? + @Published private(set) var receiptTasksTruncated: Bool? + @Published private(set) var receiptAttention: ReceiptAttentionPayload? @Published private(set) var receipt: Receipt? @Published private(set) var receiptListError: String? @Published private(set) var receiptError: String? @@ -68,6 +71,8 @@ final class DashboardStore: ObservableObject { case .populated: receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total + receiptTasksTruncated = fixture.tasks.truncated + receiptAttention = fixture.tasks.attention receipt = fixture.work?.receipt for session in fixture.work?.sessions ?? [] { let key = "\(session.session.client)::\(session.session.clientSessionId)" @@ -76,15 +81,26 @@ final class DashboardStore: ObservableObject { case .empty: receiptTasks = [] totalReceiptTasks = 0 + receiptTasksTruncated = false case .listError: receiptTasks = [] receiptListError = "receipts fetch failed: synthetic review error" + case .listErrorWithRetainedData: + receiptTasks = fixture.tasks.tasks + totalReceiptTasks = fixture.tasks.total + receiptTasksTruncated = fixture.tasks.truncated + receiptAttention = fixture.tasks.attention + receiptListError = "receipts fetch failed: synthetic review error" case .receiptLoading: receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total + receiptTasksTruncated = fixture.tasks.truncated + receiptAttention = fixture.tasks.attention case .receiptError: receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total + receiptTasksTruncated = fixture.tasks.truncated + receiptAttention = fixture.tasks.attention receiptError = "receipt fetch failed: synthetic review error" } let updated = fixture.glance.generatedAt.map(Date.init(timeIntervalSince1970:)) @@ -111,6 +127,8 @@ final class DashboardStore: ObservableObject { let tasks = try await tasksRequest receiptTasks = tasks.tasks totalReceiptTasks = tasks.total + receiptTasksTruncated = tasks.truncated + receiptAttention = tasks.attention receiptListError = nil tasksSucceeded = true } catch GlanceClientError.noDiscovery(_) { @@ -156,6 +174,8 @@ final class DashboardStore: ObservableObject { let payload: ReceiptTasksPayload = try await client.getAuthed("/v1/tasks?limit=200") receiptTasks = payload.tasks totalReceiptTasks = payload.total + receiptTasksTruncated = payload.truncated + receiptAttention = payload.attention receiptListError = nil } catch GlanceClientError.noDiscovery(_) { receiptListError = "daemon not running (no discovery file) — start it with `agentacct start`" diff --git a/apps/agentacct/Sources/agentacct/V1Model.swift b/apps/agentacct/Sources/agentacct/V1Model.swift index 84c3817..87a3a68 100644 --- a/apps/agentacct/Sources/agentacct/V1Model.swift +++ b/apps/agentacct/Sources/agentacct/V1Model.swift @@ -408,6 +408,16 @@ struct ReceiptTasksPayload: Decodable { let tasks: [ReceiptSummary] let total: Int? let truncated: Bool? + /// Exact all-store attention count plus a bounded Dashboard preview. + /// Optional so the app can fail closed against an older daemon. + let attention: ReceiptAttentionPayload? +} + +struct ReceiptAttentionPayload: Decodable { + let tasks: [ReceiptSummary] + let total: Int + let limit: Int? + let truncated: Bool } struct ReceiptSummary: Decodable, Identifiable { diff --git a/apps/agentacct/Tests/README.md b/apps/agentacct/Tests/README.md index ccfacca..6ffc06b 100644 --- a/apps/agentacct/Tests/README.md +++ b/apps/agentacct/Tests/README.md @@ -263,6 +263,8 @@ The dashboard renderer owns this complete fixed matrix at 2x scale: | `dashboard-minimum-dark.png` | 960 × 560 pt minimum window, dark; single-column viewport | 1920 × 1120 px | | `dashboard-reference-light.png` | 1120 × 800 pt standard window, light; complete two-column dashboard | 2240 × 1600 px | | `dashboard-reference-dark.png` | 1120 × 800 pt standard window, dark; complete two-column dashboard | 2240 × 1600 px | +| `dashboard-attention-unavailable-light.png` | 1120 × 800 pt failed receipt refresh with retained cache, light; proves cached attention is not presented as current | 2240 × 1600 px | +| `dashboard-attention-unavailable-dark.png` | 1120 × 800 pt failed receipt refresh with retained cache, dark; proves cached attention is not presented as current | 2240 × 1600 px | References live under `Tests/agentacctTests/ReferenceImages/`. They are read directly from the source checkout and excluded from SwiftPM's diff --git a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift index 08f234a..7194a7a 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift @@ -317,6 +317,89 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertFalse(items[4].hasFinding) } + func testAttentionPresentationUsesCompleteAggregateAndFailsClosedForLegacyTruncation() + throws + { + let payload = try decode( + ReceiptTasksPayload.self, + from: """ + { + "schema": "agentacct.receipt.v1", + "total": 203, + "truncated": true, + "tasks": [ + { + "task_id": "recent-complete", + "decision_status": { "key": "verified" }, + "evidence_strength": { "key": "self_checked" }, + "cost": {} + } + ], + "attention": { + "total": 3, + "limit": 2, + "truncated": true, + "tasks": [ + { + "task_id": "older-finding", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {} + }, + { + "task_id": "older-blocked", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "not_gradeable" }, + "cost": {} + } + ] + } + } + """ + ) + let complete = DashboardAttentionPresentation( + recentTasks: payload.tasks, + recentTasksTruncated: payload.truncated, + attention: payload.attention + ) + + XCTAssertEqual(complete.items.map(\.id), ["older-finding", "older-blocked"]) + XCTAssertEqual(complete.totalCount, 3) + XCTAssertTrue(complete.isComplete) + XCTAssertTrue(complete.isTruncated) + + let failedRefresh = DashboardAttentionPresentation( + recentTasks: payload.tasks, + recentTasksTruncated: payload.truncated, + attention: payload.attention, + fetchError: "receipts fetch failed: connection lost" + ) + XCTAssertTrue(failedRefresh.items.isEmpty) + XCTAssertNil(failedRefresh.totalCount) + XCTAssertFalse(failedRefresh.isComplete) + XCTAssertFalse(failedRefresh.isTruncated) + XCTAssertTrue(failedRefresh.isUnavailable) + + let legacy = DashboardAttentionPresentation( + recentTasks: [], + recentTasksTruncated: true, + attention: nil + ) + XCTAssertTrue(legacy.items.isEmpty) + XCTAssertNil(legacy.totalCount) + XCTAssertFalse(legacy.isComplete) + XCTAssertTrue(legacy.isTruncated) + + let exhaustiveLegacy = DashboardAttentionPresentation( + recentTasks: [], + recentTasksTruncated: false, + attention: nil + ) + XCTAssertEqual(exhaustiveLegacy.totalCount, 0) + XCTAssertTrue(exhaustiveLegacy.isComplete) + XCTAssertFalse(exhaustiveLegacy.isTruncated) + } + @MainActor func testLocalDataFreshnessUsesTheSnapshotClock() { SnapshotMode.setFixtureDate(Date(timeIntervalSince1970: 1_000)) diff --git a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift index 36daf3e..9c845fc 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift @@ -16,6 +16,8 @@ final class DashboardSnapshotHarnessTests: XCTestCase { ExpectedArtifact(filename: "dashboard-minimum-dark.png", pixelsWide: 1920, pixelsHigh: 1120), ExpectedArtifact(filename: "dashboard-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "dashboard-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "dashboard-attention-unavailable-light.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "dashboard-attention-unavailable-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ] @MainActor @@ -38,6 +40,19 @@ final class DashboardSnapshotHarnessTests: XCTestCase { XCTAssertNotNil(fixture.glance.usage.windows.first { $0.label == "today" }) } + @MainActor + func testUnavailableReviewStateRetainsCacheOnlyToProveErrorPrecedence() throws { + let fixture = try DashboardSnapshotFixture.load(from: dashboardFixtureURL()) + let store = DashboardStore( + preloaded: fixture, + workState: .listErrorWithRetainedData + ) + + XCTAssertNotNil(store.receiptAttention) + XCTAssertFalse(store.receiptTasks.isEmpty) + XCTAssertNotNil(store.receiptListError) + } + @MainActor func testRendersEveryDashboardReviewConfiguration() throws { let fixture = try DashboardSnapshotFixture.load(from: dashboardFixtureURL()) diff --git a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift index 3ae91f9..11045bd 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift @@ -10,6 +10,8 @@ final class DashboardVisualRegressionTests: XCTestCase { "dashboard-minimum-dark.png", "dashboard-reference-light.png", "dashboard-reference-dark.png", + "dashboard-attention-unavailable-light.png", + "dashboard-attention-unavailable-dark.png", ] @MainActor diff --git a/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json b/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json index 9fea427..82ab16a 100644 --- a/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json +++ b/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json @@ -392,7 +392,46 @@ }, "last_activity_at": 1787589280 } - ] + ], + "attention": { + "total": 2, + "limit": 100, + "truncated": false, + "tasks": [ + { + "task_id": "task-visual-regression", + "title": "Review dashboard visual regression", + "decision_status": { + "key": "finding", + "label": "Open finding", + "asserted_by": "machine" + }, + "evidence_strength": { + "key": "unchecked", + "gradeable": true, + "checks_failed": 1 + }, + "cost": {}, + "last_activity_at": 1787589280 + }, + { + "task_id": "task-provider-calibration", + "title": "Resolve provider calibration", + "decision_status": { + "key": "blocked", + "label": "Blocked", + "asserted_by": "agent_report" + }, + "evidence_strength": { + "key": "not_gradeable", + "gradeable": false, + "checks_failed": 0 + }, + "cost": {}, + "last_activity_at": 1787589100 + } + ] + } }, "work": { "receipt": { diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-dark.png new file mode 100644 index 0000000..fccbb39 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-light.png new file mode 100644 index 0000000..297ccc9 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-attention-unavailable-light.png differ diff --git a/src/agentacct/api.py b/src/agentacct/api.py index 44d687d..f262209 100644 --- a/src/agentacct/api.py +++ b/src/agentacct/api.py @@ -126,6 +126,7 @@ # history lives on /sessions. # The pinned Needs-attention strip shows the newest open findings/blockers; the # rest stay one click away so the strip can never bury the recent-activity feed. +DASHBOARD_RECEIPT_ATTENTION_LIMIT = 2 MECHANICAL_PROJECTION_LIMIT = 10_000 # Conflict repair is a safety path for timestamp-only retry variants. Keep its # work globally bounded so a conflict-heavy store cannot turn one Work page @@ -1178,6 +1179,74 @@ def ordered(candidates: list[Mapping[str, Any]]) -> list[Mapping[str, Any]]: return f"{client_label} in {project}" if project else f"Untitled {client_label} chat" +def _receipt_attention_priority(summary: Mapping[str, Any]) -> int | None: + """Dashboard review priority for one compact Receipt summary. + + Open machine findings and recorded failures come before agent-reported + blockers. Human-resolved or superseded findings retain their failed-check + history without returning to the attention queue. + """ + + decision = summary.get("decision_status") + evidence = summary.get("evidence_strength") + decision_key = str( + decision.get("key") if isinstance(decision, Mapping) else "" + ).strip() + failed_checks = ( + int(evidence.get("checks_failed") or 0) + if isinstance(evidence, Mapping) + else 0 + ) + settled_finding_keys = {"finding_superseded", "finding_resolved_by_user"} + has_finding = ( + failed_checks > 0 and decision_key not in settled_finding_keys + ) or decision_key in {"finding", "failed"} + if has_finding: + return 0 + if decision_key == "blocked": + return 1 + return None + + +def _dashboard_receipt_attention( + tasks: Sequence[Mapping[str, Any]], + *, + latest_store_activity_at: float | None, +) -> dict[str, Any]: + """Exact all-store attention count plus a bounded Dashboard preview. + + ``tasks`` is newest-first. Retaining at most two rows per priority class + keeps memory bounded while the full scan proves whether the queue is empty. + """ + + preview_by_priority: tuple[list[dict[str, Any]], list[dict[str, Any]]] = ([], []) + total = 0 + for task in tasks: + row = build_receipt_summary( + task, + public_task_id=str(task.get("public_task_id")), + title=_task_title(task), + latest_store_activity_at=latest_store_activity_at, + ) + priority = _receipt_attention_priority(row) + if priority is None: + continue + total += 1 + bucket = preview_by_priority[priority] + if len(bucket) < DASHBOARD_RECEIPT_ATTENTION_LIMIT: + bucket.append(row) + + preview = (preview_by_priority[0] + preview_by_priority[1])[ + :DASHBOARD_RECEIPT_ATTENTION_LIMIT + ] + return { + "tasks": preview, + "total": total, + "limit": DASHBOARD_RECEIPT_ATTENTION_LIMIT, + "truncated": len(preview) < total, + } + + def _finding_form_token(secret: str, event: Mapping[str, Any]) -> str | None: target_digest = finding_target_digest(event) if not secret or target_digest is None: @@ -2981,6 +3050,15 @@ def _v1_task_projection() -> dict[str, Any]: # Weekly-plan shares ride the same cached projection: deterministic # from the same event log, so the cache key already covers them. _stamp_task_plan_shares(projection, events) + attention_tasks = _visible_tasks(projection) + attention_tasks.sort( + key=lambda task: float(task.get("last_activity_at") or 0.0), + reverse=True, + ) + projection["_dashboard_receipt_attention"] = _dashboard_receipt_attention( + attention_tasks, + latest_store_activity_at=latest_store_activity(attention_tasks), + ) v1_receipt_projection_cache["projection"] = (fingerprint, time.time(), projection) return projection @@ -3001,7 +3079,8 @@ def v1_tasks( (the two axes + cost + activity), newest first. A Task is the convergence of a root session with its continuations and subagents — the unit a Receipt is written for. Cheap under polling (cached - projection); the per-request work is a summary map + slice.""" + projection); the complete attention count and bounded preview are built + once per cache refresh, while each request maps only its recent slice.""" _require_v1_token(request) projection = _v1_task_projection() @@ -3026,6 +3105,9 @@ def v1_tasks( "offset": offset, "limit": limit, "truncated": offset + limit < total, + # Additive, exact across every visible Task, and preview-bounded. + # Unlike ``tasks``, this is never scoped to the recent page. + "attention": projection["_dashboard_receipt_attention"], } @app.get("/v1/receipt") diff --git a/tests/test_receipt_api.py b/tests/test_receipt_api.py index 486ad86..4d94014 100644 --- a/tests/test_receipt_api.py +++ b/tests/test_receipt_api.py @@ -11,6 +11,7 @@ _LEDGER_RUN_REPORT_LIMIT, _collect_service_run_reports, _dashboard_task_projection, + _receipt_attention_priority, _mechanical_projection_envelopes_for, _store_scope_and_label, build_mechanical_check_events, @@ -250,6 +251,72 @@ def test_tasks_list_and_receipt_detail_for_an_observed_task(tmp_path: Path) -> N assert receipt["dimensions"]["cost"]["cost_basis"] == "pricing_table" +def test_tasks_attention_summary_includes_actionable_work_beyond_recent_window( + tmp_path: Path, +) -> None: + service = SentinelService(tmp_path) + for index in range(3): + session_id = f"older-blocked-{index}" + _record_usage(service, session_id=session_id, at=1.0 + index * 2) + _record_section( + service, + session_id=session_id, + section_id=f"blocked-section-{index}", + status="blocked", + at=2.0 + index * 2, + ) + for index in range(200): + _record_usage( + service, + session_id=f"recent-{index}", + at=2_000_000_000.0 + index, + ) + + listing = _app(tmp_path).get( + "/v1/tasks", + headers=_auth(), + params={"limit": 200}, + ).json() + + assert listing["total"] == 203 + assert len(listing["tasks"]) == 200 + assert listing["truncated"] is True + recent_ids = {row["task_id"] for row in listing["tasks"]} + assert listing["attention"]["total"] == 3 + assert listing["attention"]["limit"] == 2 + assert listing["attention"]["truncated"] is True + assert len(listing["attention"]["tasks"]) == 2 + attention_activity = [ + row["last_activity_at"] for row in listing["attention"]["tasks"] + ] + assert attention_activity == sorted(attention_activity, reverse=True) + assert all( + row["decision_status"]["key"] == "blocked" + and row["task_id"] not in recent_ids + for row in listing["attention"]["tasks"] + ) + + +def test_receipt_attention_priority_matches_dashboard_review_contract() -> None: + cases = [ + ("reported", 1, 0), + ("finding", 0, 0), + ("failed", 0, 0), + ("blocked", 0, 1), + ("finding_superseded", 1, None), + ("finding_resolved_by_user", 1, None), + ("verified", 0, None), + ] + + for decision, failed_checks, expected in cases: + assert _receipt_attention_priority( + { + "decision_status": {"key": decision}, + "evidence_strength": {"checks_failed": failed_checks}, + } + ) == expected + + def test_unknown_task_is_a_404_not_an_empty_fabrication(tmp_path: Path) -> None: service = SentinelService(tmp_path) _record_usage(service, session_id="s1", at=100.0)