diff --git a/apps/agentacct/Sources/agentacct/DashboardPane.swift b/apps/agentacct/Sources/agentacct/DashboardPane.swift index 53228f7..e906c08 100644 --- a/apps/agentacct/Sources/agentacct/DashboardPane.swift +++ b/apps/agentacct/Sources/agentacct/DashboardPane.swift @@ -10,6 +10,7 @@ import SwiftUI struct DashboardWorkItem: Identifiable { let id: String let title: String + let project: String? let client: String let lastActivityAt: Double? let outcome: String @@ -21,12 +22,9 @@ struct DashboardWorkItem: Identifiable { init(task: ReceiptSummary) { id = task.taskId - if let taskTitle = task.title, !taskTitle.isEmpty { - title = taskTitle - } else { - title = task.taskId - } - client = task.primaryRoot?.client ?? "Unknown agent" + title = recordedTaskDisplayTitle(task.title, taskId: task.taskId) + project = Self.nonempty(task.project) + client = Self.nonempty(task.primaryRoot?.client) ?? "Unknown agent" lastActivityAt = task.lastActivityAt outcomeKey = task.decisionStatus.key if let label = task.decisionStatus.label, !label.isEmpty { @@ -44,6 +42,12 @@ struct DashboardWorkItem: Identifiable { agoText(lastActivityAt) } + var contextComponents: [String] { + [project, client, recency].compactMap { $0 } + } + + var visibleCost: String { cost == "—" ? "unpriced" : cost } + /// Strongest evidence tier present (drives the row's tier pip). var strongestTier: String? { gradeable ? (strongestTierKey ?? "unchecked") : nil @@ -61,6 +65,15 @@ struct DashboardWorkItem: Identifiable { } } + private static func nonempty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty + else { + return nil + } + return trimmed + } + private static func compactCost(_ cost: ReceiptCost) -> String { guard let value = cost.estimatedCostUsd else { return "—" } let prefix: String @@ -93,19 +106,15 @@ struct DashboardAttentionItem: Identifiable, Equatable { init?(task: ReceiptSummary) { guard let reason = task.attention else { return nil } id = task.taskId - if let taskTitle = task.title, !taskTitle.isEmpty { - title = taskTitle - } else { - title = task.taskId - } - project = task.project - client = task.primaryRoot?.client + title = recordedTaskDisplayTitle(task.title, taskId: task.taskId) + project = Self.nonempty(task.project) + client = Self.nonempty(task.primaryRoot?.client) reasonKind = reason.kind - summary = reason.summary - nextStep = reason.nextStep + summary = reason.summary.trimmingCharacters(in: .whitespacesAndNewlines) + nextStep = Self.nonempty(reason.nextStep) observedAt = reason.observedAt handedOff = task.handedOff - switch reason.source { + switch Self.nonempty(reason.source) { case "mcp": sourceLabel = "MCP record" case "client_log": sourceLabel = "Local client log" case "machine": sourceLabel = "Machine check" @@ -130,6 +139,15 @@ struct DashboardAttentionItem: Identifiable, Equatable { } } + private static func nonempty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), + !value.isEmpty + else { + return nil + } + return value + } + var recency: String? { agoText(observedAt) } } @@ -236,7 +254,7 @@ enum DashboardAttentionPresentation: Equatable { self = .loading return } - guard Self.hasConsistentEnvelope(payload) else { + guard hasConsistentAttentionHeadEnvelope(payload) else { self = .inconsistent(total: max(0, payload.total)) return } @@ -251,36 +269,6 @@ enum DashboardAttentionPresentation: Equatable { } } - private static func hasConsistentEnvelope(_ payload: V1AttentionPayload) -> Bool { - let taskIDs = payload.items.map { - $0.taskId.trimmingCharacters(in: .whitespacesAndNewlines) - } - guard payload.schema == "agentacct.v1-attention.v1", - payload.total >= 0, - payload.offset == 0, - (1 ... 50).contains(payload.limit), - payload.counts.failedCheck >= 0, - payload.counts.failedStep >= 0, - payload.counts.blocker >= 0, - payload.items.count <= payload.limit, - payload.items.count <= payload.total, - taskIDs.allSatisfy({ !$0.isEmpty }), - Set(taskIDs).count == payload.items.count, - payload.items.allSatisfy({ $0.attention != nil }), - payload.total == 0 || !payload.items.isEmpty, - payload.truncated == (payload.total > payload.items.count) - else { - return false - } - - let first = payload.counts.failedCheck.addingReportingOverflow( - payload.counts.failedStep - ) - guard !first.overflow else { return false } - let total = first.partialValue.addingReportingOverflow(payload.counts.blocker) - return !total.overflow && total.partialValue == payload.total - } - var dashboardHeadline: String { switch self { case .loading: return "Checking recorded work" @@ -294,7 +282,7 @@ enum DashboardAttentionPresentation: Equatable { var dashboardStatus: String { switch self { case .loading: return "Loading review projection" - case .unavailable: return "Refresh to retry" + case .unavailable: return "Unavailable" case .clear: return "0 review items" case .focus(_, let total), .inconsistent(let total): return "\(total) review item\(total == 1 ? "" : "s")" @@ -800,7 +788,9 @@ struct DashboardPane: View { RecentWorkCard( items: recentWork, - totalCount: dashboard.totalReceiptTasks ?? dashboard.receiptTasks.count + totalCount: dashboard.totalReceiptTasks, + hasLoaded: dashboard.hasLoadedReceiptTasks, + error: dashboard.receiptListError ) { destination in selection.open(destination) } @@ -1467,11 +1457,43 @@ private extension DashboardCardHeader where Action == EmptyView { } } +enum DashboardRecentWorkPresentation: Equatable { + case loading + case unavailable(String) + case empty + case populated + + init(items: [DashboardWorkItem], total: Int?, hasLoaded: Bool, error: String?) { + if !items.isEmpty { + self = .populated + } else if let error { + self = .unavailable(error) + } else if !hasLoaded { + self = .loading + } else if let total, total > 0 { + self = .unavailable("The receipt count loaded, but no recent rows were returned.") + } else { + self = .empty + } + } +} + private struct RecentWorkCard: View { let items: [DashboardWorkItem] - let totalCount: Int + let totalCount: Int? + let hasLoaded: Bool + let error: String? let open: (DashboardDestination) -> Void + private var presentation: DashboardRecentWorkPresentation { + DashboardRecentWorkPresentation( + items: items, + total: totalCount, + hasLoaded: hasLoaded, + error: error + ) + } + var body: some View { Card(padding: 0, fillsHeight: true) { VStack(spacing: 0) { @@ -1485,14 +1507,43 @@ private struct RecentWorkCard: View { } Divider().overlay(Theme.hairline) - if items.isEmpty { + switch presentation { + case .loading: + HStack(spacing: Space.m) { + ProgressView().controlSize(.small).tint(Theme.muted) + Text("Loading recent work…") + .font(Type.body) + .foregroundStyle(Theme.muted) + } + .padding(Space.xl) + .frame(maxWidth: .infinity, minHeight: 222, alignment: .leading) + case .unavailable(let message): + DashboardEmptyState( + icon: "exclamationmark.triangle", + title: "Recent work unavailable", + message: message + ) + .frame(minHeight: 222) + case .empty: DashboardEmptyState( icon: "checklist", title: "No recorded work yet", message: "Set up recording to see task outcomes and evidence here." ) .frame(minHeight: 222) - } else { + case .populated: + if let error { + Label( + "Showing last loaded work · \(error)", + systemImage: "exclamationmark.triangle.fill" + ) + .font(Type.dataSmall) + .foregroundStyle(Theme.amber) + .padding(.horizontal, Space.l) + .padding(.vertical, Space.s) + .frame(maxWidth: .infinity, alignment: .leading) + Divider().overlay(Theme.hairline) + } workColumnLabels Divider().overlay(Theme.hairline) ForEach(Array(items.enumerated()), id: \.element.id) { index, item in @@ -1533,7 +1584,7 @@ private struct RecentWorkRow: View { .font(Type.rowLabel) .foregroundStyle(Theme.ink) .lineLimit(2) - Text([item.client, item.recency].compactMap { $0 }.joined(separator: " · ")) + Text(item.contextComponents.joined(separator: " · ")) .font(Type.caption) .foregroundStyle(Theme.muted) .lineLimit(1) @@ -1560,7 +1611,7 @@ private struct RecentWorkRow: View { } .frame(width: 118, alignment: .leading) - Text(item.cost == "—" ? "unpriced" : item.cost) + Text(item.visibleCost) .font(Type.dataSmall) .foregroundStyle(Theme.muted) .frame(width: 68, alignment: .trailing) @@ -1573,7 +1624,13 @@ private struct RecentWorkRow: View { } .buttonStyle(DashboardRowButtonStyle()) .accessibilityLabel( - "\(item.title), \(item.outcome), \(item.evidence), \(item.cost)" + [ + item.title, + item.contextComponents.joined(separator: ", "), + item.outcome, + item.evidence, + item.visibleCost, + ].joined(separator: ", ") ) .accessibilityHint("Opens this task in Work") .accessibilityIdentifier("dashboard.recent-work.task.\(item.id)") diff --git a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift index 3757f2d..d2d5b8b 100644 --- a/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift +++ b/apps/agentacct/Sources/agentacct/DashboardSnapshotHarness.swift @@ -135,6 +135,23 @@ struct DashboardSnapshotConfiguration { let height: CGFloat let colorScheme: ColorScheme let workState: SnapshotWorkStoreState + let glanceState: DashboardSnapshotGlanceState + + init( + viewport: String, + width: CGFloat, + height: CGFloat, + colorScheme: ColorScheme, + workState: SnapshotWorkStoreState, + glanceState: DashboardSnapshotGlanceState = .fixture + ) { + self.viewport = viewport + self.width = width + self.height = height + self.colorScheme = colorScheme + self.workState = workState + self.glanceState = glanceState + } var filename: String { let appearance = colorScheme == .dark ? "dark" : "light" @@ -151,9 +168,48 @@ struct DashboardSnapshotConfiguration { Self(viewport: "reference", width: 1120, height: 800, colorScheme: .dark, workState: .populated), Self(viewport: "trust-unavailable", width: 1120, height: 800, colorScheme: .light, workState: .shiftBriefUnavailable), Self(viewport: "trust-unavailable", width: 1120, height: 800, colorScheme: .dark, workState: .shiftBriefUnavailable), + Self(viewport: "old-daemon-statusless", width: 1120, height: 800, colorScheme: .light, workState: .oldDaemonUnavailable, glanceState: .statuslessUsage), + Self(viewport: "old-daemon-statusless", width: 1120, height: 800, colorScheme: .dark, workState: .oldDaemonUnavailable, glanceState: .statuslessUsage), ] } +enum DashboardSnapshotGlanceState { + case fixture + case statuslessUsage + + func snapshot(from fixture: DashboardSnapshotFixture) -> GlanceSnapshot { + guard self == .statuslessUsage else { return fixture.glanceSnapshot } + let glance = fixture.glance + let generatedAt = glance.generatedAt ?? 0 + let ids = [ + "01a046ac", "01a046bd", "01a046ce", "01a046df", + "01a046e0", "01a046f1", "01a04702", "01a04713", + ] + let sessions = ids.enumerated().map { index, id in + RecentSession( + client: "codex", + sessionId: "\(id)-statusless", + title: nil, + status: nil, + lastActivityAt: generatedAt - Double(51 + index * 22), + planPct: nil + ) + } + return GlanceSnapshot( + glance: Glance( + schema: glance.schema, + generatedAt: glance.generatedAt, + daemon: glance.daemon, + usage: glance.usage, + limits: glance.limits, + plan: glance.plan, + recentSessions: sessions + ), + daemonVersion: fixture.daemonVersion + ) + } +} + enum DashboardSnapshotRenderer { private static let snapshotLocale = Locale(identifier: "en_US_POSIX") private static let snapshotTimeZone = TimeZone(secondsFromGMT: 0)! @@ -193,7 +249,9 @@ enum DashboardSnapshotRenderer { return try configurations.map { configuration in SnapshotScheme.override = configuration.colorScheme - let glance = GlanceState(preloaded: fixture.glanceSnapshot) + let glance = GlanceState( + preloaded: configuration.glanceState.snapshot(from: fixture) + ) let dashboard = DashboardStore( preloaded: fixture, workState: configuration.workState diff --git a/apps/agentacct/Sources/agentacct/DashboardStore.swift b/apps/agentacct/Sources/agentacct/DashboardStore.swift index 7770d54..66b50ff 100644 --- a/apps/agentacct/Sources/agentacct/DashboardStore.swift +++ b/apps/agentacct/Sources/agentacct/DashboardStore.swift @@ -1,57 +1,31 @@ import Foundation import SwiftUI -struct LatestRequestGeneration { - private(set) var current = 0 - - mutating func begin() -> Int { - current += 1 - return current - } - - func accepts(_ generation: Int) -> Bool { - generation == current +enum DashboardDaemonFeature { + case attention + case ingestion + + var upgradeMessage: String { + switch self { + case .attention: + return "Update agentacct, then restart its local service to enable review status." + case .ingestion: + return "Update agentacct, then restart its local service to enable source status." + } } } - -func mergedAttentionPages( - _ current: V1AttentionPayload, - _ next: V1AttentionPayload -) -> V1AttentionPayload { - var seen = Set() - let items = (current.items + next.items).filter { seen.insert($0.taskId).inserted } - return V1AttentionPayload( - schema: next.schema, - items: items, - total: next.total, - counts: next.counts, - snapshot: next.snapshot, - offset: current.offset, - limit: items.count, - truncated: next.truncated - ) -} - -func attentionPageCanAppend( - _ current: V1AttentionPayload, - _ next: V1AttentionPayload -) -> Bool { - current.snapshot != nil - && next.snapshot == current.snapshot - && next.schema == current.schema - && next.offset == current.offset + current.items.count - && next.total == current.total - && next.counts == current.counts -} - /// Named state variants used only by deterministic offscreen review tooling. /// Keeping the mutation inside DashboardStore preserves its private setters; /// the live initializer and network lifecycle remain unchanged. enum SnapshotWorkStoreState { case populated + case loading case empty case listError + case retainedListError case shiftBriefUnavailable + case oldDaemonUnavailable + case attentionOverflow case receiptLoading case receiptError } @@ -68,9 +42,12 @@ 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 hasLoadedReceiptTasks = false /// Complete review classification plus a bounded operational queue. @Published private(set) var attention: V1AttentionPayload? @Published private(set) var attentionError: String? + @Published private(set) var attentionQueueItems: [ReceiptSummary] = [] + @Published private(set) var attentionPageError: String? @Published private(set) var isLoadingMoreAttention = false @Published private(set) var receipt: Receipt? @Published private(set) var receiptListError: String? @@ -99,7 +76,8 @@ final class DashboardStore: ObservableObject { /// Monotonic token so rapid range switches can't land out of order and a /// failed fetch can't leave the old data labeled with the new range. private var usageDaysGeneration = 0 - private var attentionGeneration = LatestRequestGeneration() + private var attentionGeneration = 0 + private var receiptListGeneration = 0 private let client = GlanceClient() @@ -114,9 +92,11 @@ final class DashboardStore: ObservableObject { planClients = fixture.plan.clients usage = fixture.usage attention = fixture.attention + attentionQueueItems = fixture.attention.items ingestion = fixture.ingestion?.ingestion switch workState { case .populated: + hasLoadedReceiptTasks = true receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total receipt = fixture.work?.receipt @@ -124,7 +104,10 @@ final class DashboardStore: ObservableObject { let key = "\(session.session.client)::\(session.session.clientSessionId)" preloadedSessions[key] = session } + case .loading: + break case .empty: + hasLoadedReceiptTasks = true receiptTasks = [] totalReceiptTasks = 0 attention = V1AttentionPayload( @@ -132,23 +115,56 @@ final class DashboardStore: ObservableObject { items: [], total: 0, counts: V1AttentionCounts(failedCheck: 0, failedStep: 0, blocker: 0), - snapshot: nil, - offset: 0, + revision: fixture.attention.revision, + offset: fixture.attention.revision == nil ? nil : 0, limit: fixture.attention.limit, truncated: false ) case .listError: receiptTasks = [] receiptListError = "receipts fetch failed: synthetic review error" + case .retainedListError: + hasLoadedReceiptTasks = true + receiptTasks = fixture.tasks.tasks + totalReceiptTasks = fixture.tasks.total + receiptListError = "receipts fetch failed: synthetic review error" case .shiftBriefUnavailable: + hasLoadedReceiptTasks = true receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total attentionError = "attention fetch failed: synthetic review error" ingestionError = "source health fetch failed: synthetic review error" + case .oldDaemonUnavailable: + hasLoadedReceiptTasks = true + receiptTasks = fixture.tasks.tasks + totalReceiptTasks = fixture.tasks.total + attention = nil + attentionQueueItems = [] + ingestion = nil + attentionError = DashboardDaemonFeature.attention.upgradeMessage + ingestionError = DashboardDaemonFeature.ingestion.upgradeMessage + case .attentionOverflow: + hasLoadedReceiptTasks = true + receiptTasks = fixture.tasks.tasks + totalReceiptTasks = fixture.tasks.total + let overflow = V1AttentionPayload( + schema: fixture.attention.schema, + items: fixture.attention.items, + total: 7, + counts: V1AttentionCounts(failedCheck: 4, failedStep: 1, blocker: 2), + revision: fixture.attention.revision ?? "snapshot-overflow-revision", + offset: 0, + limit: 5, + truncated: true + ) + attention = overflow + attentionQueueItems = overflow.items case .receiptLoading: + hasLoadedReceiptTasks = true receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total case .receiptError: + hasLoadedReceiptTasks = true receiptTasks = fixture.tasks.tasks totalReceiptTasks = fixture.tasks.total receiptError = "receipt fetch failed: synthetic review error" @@ -164,7 +180,8 @@ final class DashboardStore: ObservableObject { defer { isRefreshing = false } let days = usageDays let rangeGeneration = usageDaysGeneration - let attentionRequestGeneration = attentionGeneration.begin() + let receiptRequestGeneration = beginReceiptListRequest() + let attentionRequestGeneration = beginAttentionRequest() isLoadingMoreAttention = false // Launch independent lanes together, but publish each error through // its own state so a successful range request cannot hide a stale Task @@ -178,38 +195,41 @@ final class DashboardStore: ObservableObject { var tasksSucceeded = false do { let tasks = try await tasksRequest - receiptTasks = tasks.tasks - totalReceiptTasks = tasks.total - receiptListError = nil - tasksSucceeded = true + if receiptRequestGeneration == receiptListGeneration { + publishReceiptList(tasks, requestGeneration: receiptRequestGeneration) + tasksSucceeded = true + } } catch GlanceClientError.noDiscovery(_) { - receiptListError = "daemon not running (no discovery file) — start it with `agentacct start`" + publishReceiptListFailure( + "daemon not running (no discovery file) — start it with `agentacct start`", + requestGeneration: receiptRequestGeneration + ) } catch { - receiptListError = "receipts fetch failed: \(error.localizedDescription)" + publishReceiptListFailure( + "receipts fetch failed: \(error.localizedDescription)", + requestGeneration: receiptRequestGeneration + ) } do { let payload = try await attentionRequest - if attentionGeneration.accepts(attentionRequestGeneration) { - attention = payload - attentionError = nil - } + publishAttentionHead(payload, requestGeneration: attentionRequestGeneration) } catch GlanceClientError.http(404) { - if attentionGeneration.accepts(attentionRequestGeneration) { - // A pre-attention daemon cannot support a complete review claim. - attention = nil - attentionError = "this daemon predates /v1/attention" - } + // A pre-attention daemon cannot support a complete review claim. + publishAttentionFailure( + DashboardDaemonFeature.attention.upgradeMessage, + requestGeneration: attentionRequestGeneration + ) } catch GlanceClientError.noDiscovery(_) { - if attentionGeneration.accepts(attentionRequestGeneration) { - attention = nil - attentionError = "daemon not running (no discovery file) — start it with `agentacct start`" - } + publishAttentionFailure( + "daemon not running (no discovery file) — start it with `agentacct start`", + requestGeneration: attentionRequestGeneration + ) } catch { - if attentionGeneration.accepts(attentionRequestGeneration) { - attention = nil - attentionError = "attention fetch failed: \(error.localizedDescription)" - } + publishAttentionFailure( + "attention fetch failed: \(error.localizedDescription)", + requestGeneration: attentionRequestGeneration + ) } do { @@ -218,7 +238,7 @@ final class DashboardStore: ObservableObject { ingestionError = nil } catch GlanceClientError.http(404) { // An older daemon without the route: a named state, not an error toast. - ingestionError = "this daemon predates /v1/ingestion" + ingestionError = DashboardDaemonFeature.ingestion.upgradeMessage } catch GlanceClientError.noDiscovery(_) { ingestionError = "daemon not running (no discovery file) — start it with `agentacct start`" } catch { @@ -245,79 +265,196 @@ final class DashboardStore: ObservableObject { /// The Task list for the Receipts pane (one compact Receipt summary each). func fetchReceipts() async { + // The window refresh already owns this lane. Starting a second request + // here would advance the generation; if Work then disappears and its + // task is cancelled, the still-valid window response could be rejected. + guard !isRefreshing else { return } + let requestGeneration = beginReceiptListRequest() do { let payload: ReceiptTasksPayload = try await client.getAuthed("/v1/tasks?limit=200") - receiptTasks = payload.tasks - totalReceiptTasks = payload.total - receiptListError = nil + guard !Task.isCancelled else { return } + publishReceiptList(payload, requestGeneration: requestGeneration) + } catch is CancellationError { + return + } catch let error as URLError where error.code == .cancelled { + return } catch GlanceClientError.noDiscovery(_) { - receiptListError = "daemon not running (no discovery file) — start it with `agentacct start`" + publishReceiptListFailure( + "daemon not running (no discovery file) — start it with `agentacct start`", + requestGeneration: requestGeneration + ) } catch { - receiptListError = "receipts fetch failed: \(error.localizedDescription)" + publishReceiptListFailure( + "receipts fetch failed: \(error.localizedDescription)", + requestGeneration: requestGeneration + ) } } + @discardableResult + func beginReceiptListRequest() -> Int { + receiptListGeneration += 1 + return receiptListGeneration + } + + func publishReceiptList(_ payload: ReceiptTasksPayload, requestGeneration: Int) { + guard requestGeneration == receiptListGeneration else { return } + receiptTasks = payload.tasks + totalReceiptTasks = payload.total + hasLoadedReceiptTasks = true + receiptListError = nil + } + + func publishReceiptListFailure(_ message: String, requestGeneration: Int) { + guard requestGeneration == receiptListGeneration else { return } + receiptListError = message + } + /// Refresh the complete attention classification independently of the /// paginated Receipt list. Used after a human disposition changes whether /// a finding or blocker still demands review. func fetchAttention() async { - let generation = attentionGeneration.begin() + let requestGeneration = beginAttentionRequest() isLoadingMoreAttention = false do { - let payload: V1AttentionPayload = try await client.getAuthed("/v1/attention?limit=50&offset=0") - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attention = payload - attentionError = nil + let payload: V1AttentionPayload = try await client.getAuthed("/v1/attention?limit=5") + guard !Task.isCancelled else { return } + publishAttentionHead(payload, requestGeneration: requestGeneration) + } catch is CancellationError { + return + } catch let error as URLError where error.code == .cancelled { + return } catch GlanceClientError.http(404) { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attention = nil - attentionError = "this daemon predates /v1/attention" + publishAttentionFailure( + DashboardDaemonFeature.attention.upgradeMessage, + requestGeneration: requestGeneration + ) } catch GlanceClientError.noDiscovery(_) { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attention = nil - attentionError = "daemon not running (no discovery file) — start it with `agentacct start`" + publishAttentionFailure( + "daemon not running (no discovery file) — start it with `agentacct start`", + requestGeneration: requestGeneration + ) } catch { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } + publishAttentionFailure( + "attention fetch failed: \(error.localizedDescription)", + requestGeneration: requestGeneration + ) + } + } + + @discardableResult + func beginAttentionRequest() -> Int { + attentionGeneration += 1 + return attentionGeneration + } + + func publishAttentionHead(_ payload: V1AttentionPayload, requestGeneration: Int) { + guard requestGeneration == attentionGeneration else { return } + guard hasConsistentAttentionHeadEnvelope(payload) else { attention = nil - attentionError = "attention fetch failed: \(error.localizedDescription)" + attentionQueueItems = [] + attentionError = "Review status response was inconsistent. Refresh before acting on it." + attentionPageError = nil + return } + attentionQueueItems = attentionItemsAfterHeadRefresh( + existing: attentionQueueItems, + previous: attention, + refreshed: payload + ) + attention = payload + attentionError = nil + attentionPageError = nil + } + + func publishAttentionFailure(_ message: String, requestGeneration: Int) { + guard requestGeneration == attentionGeneration else { return } + attention = nil + attentionQueueItems = [] + attentionError = message + attentionPageError = nil + } + + var hasMoreAttention: Bool { + guard let attention else { return false } + return attentionQueueItems.count < attention.total } + var supportsAttentionPaging: Bool { + guard let attention else { return false } + return attention.offset != nil && attention.revision != nil + } + + var canLoadMoreAttention: Bool { + hasMoreAttention && supportsAttentionPaging + } + + /// Fetch the next server-ranked page without replacing the Shift Brief's + /// complete count or leading row. Page compatibility is validated before + /// publishing so older daemons that ignore `offset` cannot duplicate page + /// one and make a bounded queue look complete. func fetchMoreAttention() async { - guard let current = attention, current.truncated, !isLoadingMoreAttention else { return } - let generation = attentionGeneration.begin() + guard attention != nil, + canLoadMoreAttention, + !isLoadingMoreAttention + else { + return + } + isLoadingMoreAttention = true + let generation = attentionGeneration defer { - if attentionGeneration.accepts(generation) { isLoadingMoreAttention = false } + if generation == attentionGeneration { + isLoadingMoreAttention = false + } } - let offset = current.offset + current.items.count + let offset = attentionQueueItems.count do { let page: V1AttentionPayload = try await client.getAuthed( "/v1/attention?limit=50&offset=\(offset)" ) - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - guard page.offset == offset else { - attentionError = "this daemon predates paged /v1/attention" - return - } - guard attentionPageCanAppend(current, page) else { - // The queue changed between page requests. Restart instead of - // stitching two incompatible classifications together. - await fetchAttention() - return - } - attention = mergedAttentionPages(current, page) - attentionError = nil + guard !Task.isCancelled, generation == attentionGeneration else { return } + publishAttentionPage(page, requestGeneration: generation) + } catch is CancellationError { + return + } catch let error as URLError where error.code == .cancelled { + return } catch GlanceClientError.http(404) { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attentionError = "this daemon predates paged /v1/attention" + guard generation == attentionGeneration else { return } + attentionPageError = DashboardDaemonFeature.attention.upgradeMessage } catch GlanceClientError.noDiscovery(_) { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attentionError = "daemon not running (no discovery file) — start it with `agentacct start`" + guard generation == attentionGeneration else { return } + attentionPageError = "daemon not running (no discovery file) — start it with `agentacct start`" } catch { - guard !Task.isCancelled, attentionGeneration.accepts(generation) else { return } - attentionError = "attention page fetch failed: \(error.localizedDescription)" + guard generation == attentionGeneration else { return } + attentionPageError = "review queue page failed: \(error.localizedDescription)" + } + } + + func publishAttentionPage(_ page: V1AttentionPayload, requestGeneration: Int) { + guard requestGeneration == attentionGeneration, let summary = attention else { return } + if let summaryRevision = summary.revision, + let pageRevision = page.revision, + pageRevision != summaryRevision + { + attention = nil + attentionQueueItems = [] + attentionError = "Review queue changed while loading. Refresh before acting on it." + attentionPageError = nil + return + } + guard let merged = mergedAttentionItems( + existing: attentionQueueItems, + summary: summary, + page: page + ) else { + attentionPageError = page.offset == nil + ? "Update agentacct, then restart its local service to load the complete review queue." + : "The review queue changed while loading. Refresh before acting on it." + return } + attentionQueueItems = merged + attentionPageError = nil } /// One Task's full Receipt. 404 (task unknown / recorded elsewhere) is a diff --git a/apps/agentacct/Sources/agentacct/MenuPresentation.swift b/apps/agentacct/Sources/agentacct/MenuPresentation.swift index b293dd9..8c5412a 100644 --- a/apps/agentacct/Sources/agentacct/MenuPresentation.swift +++ b/apps/agentacct/Sources/agentacct/MenuPresentation.swift @@ -10,8 +10,14 @@ struct MenuLimitItem: Identifiable, Equatable { var percentageText: String { guard let usedPercent else { return "Not reported" } + guard usedPercent.isFinite, + usedPercent >= 0, + let rounded = Int(exactly: usedPercent.rounded()) + else { + return "Invalid percentage" + } if usedPercent > 0, usedPercent < 1 { return "<1%" } - return "\(Int(usedPercent.rounded()))%" + return "\(rounded)%" } var sourceLabel: String { "\(clientLabel) · \(windowLabel)" } diff --git a/apps/agentacct/Sources/agentacct/SourcesPane.swift b/apps/agentacct/Sources/agentacct/SourcesPane.swift index f618130..bee3c6b 100644 --- a/apps/agentacct/Sources/agentacct/SourcesPane.swift +++ b/apps/agentacct/Sources/agentacct/SourcesPane.swift @@ -1,5 +1,21 @@ import SwiftUI +enum SourcesHealthAvailability: Equatable { + case loading + case unavailable(String) + case connected + + init(hasSnapshot: Bool, error: String?) { + if let error { + self = .unavailable(error) + } else if hasSnapshot { + self = .connected + } else { + self = .loading + } + } +} + // Sources — what feeds the evidence store, exactly as the ingestion-health // snapshot reports it: per-source import state and recency, the continuous- // sync watcher, actionable issues, the verifier shelf (named not-connected @@ -96,22 +112,26 @@ struct SourcesPane: View { @ViewBuilder private var content: some View { - if let snapshot = dashboard.ingestion { - connectedCard(snapshot) - watcherCard(snapshot.watcher).padding(.top, Space.xl) - issuesCard(snapshot.issues ?? []).padding(.top, Space.xl) - verifierShelf.padding(.top, Space.xl) - scopeCard.padding(.top, Space.xl) - } else if let error = dashboard.ingestionError { + switch SourcesHealthAvailability( + hasSnapshot: dashboard.ingestion != nil, + error: dashboard.ingestionError + ) { + case .connected: + if let snapshot = dashboard.ingestion { + connectedCard(snapshot) + watcherCard(snapshot.watcher).padding(.top, Space.xl) + issuesCard(snapshot.issues ?? []).padding(.top, Space.xl) + verifierShelf.padding(.top, Space.xl) + scopeCard.padding(.top, Space.xl) + } + case .unavailable(let error): VStack(alignment: .leading, spacing: 4) { Text("Source health unavailable").font(Type.rowLabel).foregroundStyle(Theme.ink) Text(error).font(Type.caption).foregroundStyle(Theme.muted) - Text("An older daemon serves no /v1/ingestion — update and restart it.") - .font(Type.caption).foregroundStyle(Theme.muted) } verifierShelf.padding(.top, Space.xl) scopeCard.padding(.top, Space.xl) - } else { + case .loading: Text("Loading source health…").font(Type.body).foregroundStyle(Theme.muted) } } diff --git a/apps/agentacct/Sources/agentacct/Theme.swift b/apps/agentacct/Sources/agentacct/Theme.swift index 49e6cff..096a47f 100644 --- a/apps/agentacct/Sources/agentacct/Theme.swift +++ b/apps/agentacct/Sources/agentacct/Theme.swift @@ -223,10 +223,14 @@ enum Theme { } static func resetsIn(_ resetsAt: Double?, now: Date = SnapshotMode.currentDate) -> String? { - guard let resetsAt else { return nil } + guard let resetsAt, resetsAt.isFinite else { return nil } let delta = resetsAt - now.timeIntervalSince1970 - guard delta > 0 else { return nil } - let total = Int(delta) + guard delta.isFinite, + delta > 0, + let total = Int(exactly: delta.rounded(.towardZero)) + else { + return nil + } let days = total / 86400 let hours = (total % 86400) / 3600 let minutes = (total % 3600) / 60 diff --git a/apps/agentacct/Sources/agentacct/UsageCapacity.swift b/apps/agentacct/Sources/agentacct/UsageCapacity.swift index adfc681..29221f3 100644 --- a/apps/agentacct/Sources/agentacct/UsageCapacity.swift +++ b/apps/agentacct/Sources/agentacct/UsageCapacity.swift @@ -235,6 +235,12 @@ struct LimitWindowPresentation { var resetText: String { guard let resetsAt = window.resetsAt else { return "Reset time not reported" } + guard resetsAt.isFinite, + (Date.distantPast.timeIntervalSince1970 ... Date.distantFuture.timeIntervalSince1970) + .contains(resetsAt) + else { + return "Invalid reset time" + } let date = Date(timeIntervalSince1970: resetsAt) let now = SnapshotMode.currentDate let time = usageResetClockText(date) diff --git a/apps/agentacct/Sources/agentacct/V1Model.swift b/apps/agentacct/Sources/agentacct/V1Model.swift index 46fb114..cb48013 100644 --- a/apps/agentacct/Sources/agentacct/V1Model.swift +++ b/apps/agentacct/Sources/agentacct/V1Model.swift @@ -419,18 +419,23 @@ struct V1AttentionPayload: Decodable { let items: [ReceiptSummary] let total: Int let counts: V1AttentionCounts - let snapshot: String? - let offset: Int + /// Content identity for the ranked review projection. Optional for daemons + /// that predate page-safe queue loading. + let revision: String? + /// Additive in newer daemons. Missing means the legacy first page. + let offset: Int? let limit: Int let truncated: Bool + var resolvedOffset: Int { offset ?? 0 } + init( schema: String, items: [ReceiptSummary], total: Int, counts: V1AttentionCounts, - snapshot: String?, - offset: Int, + revision: String?, + offset: Int?, limit: Int, truncated: Bool ) { @@ -438,14 +443,14 @@ struct V1AttentionPayload: Decodable { self.items = items self.total = total self.counts = counts - self.snapshot = snapshot + self.revision = revision self.offset = offset self.limit = limit self.truncated = truncated } private enum CodingKeys: String, CodingKey { - case schema, items, total, counts, snapshot, offset, limit, truncated + case schema, items, total, counts, revision, snapshot, offset, limit, truncated } init(from decoder: Decoder) throws { @@ -454,8 +459,17 @@ struct V1AttentionPayload: Decodable { items = try container.decode([ReceiptSummary].self, forKey: .items) total = try container.decode(Int.self, forKey: .total) counts = try container.decode(V1AttentionCounts.self, forKey: .counts) - snapshot = try container.decodeIfPresent(String.self, forKey: .snapshot) - offset = try container.decodeIfPresent(Int.self, forKey: .offset) ?? 0 + let currentRevision = try container.decodeIfPresent(String.self, forKey: .revision) + let legacySnapshot = try container.decodeIfPresent(String.self, forKey: .snapshot) + if let currentRevision, let legacySnapshot, currentRevision != legacySnapshot { + throw DecodingError.dataCorruptedError( + forKey: .revision, + in: container, + debugDescription: "revision and snapshot identify different attention queues" + ) + } + revision = currentRevision ?? legacySnapshot + offset = try container.decodeIfPresent(Int.self, forKey: .offset) limit = try container.decode(Int.self, forKey: .limit) truncated = try container.decode(Bool.self, forKey: .truncated) } @@ -473,6 +487,233 @@ struct V1AttentionCounts: Decodable, Equatable { } } +private func observedAttentionCounts( + in items: [ReceiptSummary] +) -> V1AttentionCounts? { + var failedCheck = 0 + var failedStep = 0 + var blocker = 0 + for item in items { + guard let attention = item.attention, + !attention.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { + return nil + } + switch attention.kind { + case "failed_check": failedCheck += 1 + case "failed_step": failedStep += 1 + case "blocker": blocker += 1 + default: return nil + } + } + return V1AttentionCounts( + failedCheck: failedCheck, + failedStep: failedStep, + blocker: blocker + ) +} + +private func attentionCounts( + _ observed: V1AttentionCounts, + fitWithin reported: V1AttentionCounts +) -> Bool { + observed.failedCheck <= reported.failedCheck + && observed.failedStep <= reported.failedStep + && observed.blocker <= reported.blocker +} + +/// Failed checks and failed steps share the leading operational class; all of +/// them precede blockers. A partial prefix may stop within that leading class, +/// but it cannot expose a blocker while a reported failure row is still unseen. +private func hasConsistentAttentionPrefixOrder( + _ items: [ReceiptSummary], + reported: V1AttentionCounts +) -> Bool { + let leadingTotal = reported.failedCheck.addingReportingOverflow(reported.failedStep) + guard !leadingTotal.overflow else { return false } + + var observedLeading = 0 + var reachedBlockers = false + for item in items { + switch item.attention?.kind { + case "failed_check", "failed_step": + guard !reachedBlockers else { return false } + observedLeading += 1 + case "blocker": + guard observedLeading == leadingTotal.partialValue else { return false } + reachedBlockers = true + default: + return false + } + } + return true +} + +/// Validate the complete-count and first-page contract before any surface uses +/// an attention response. Legacy daemons omit both paging fields; current +/// daemons must supply both. A partial or contradictory envelope is not safe to +/// present as a complete review projection. +func hasConsistentAttentionHeadEnvelope(_ payload: V1AttentionPayload) -> Bool { + let taskIDs = payload.items.map { + $0.taskId.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard payload.schema == "agentacct.v1-attention.v1", + payload.total >= 0, + (1 ... 50).contains(payload.limit), + payload.counts.failedCheck >= 0, + payload.counts.failedStep >= 0, + payload.counts.blocker >= 0, + payload.items.count <= payload.limit, + payload.items.count <= payload.total, + taskIDs.allSatisfy({ !$0.isEmpty }), + zip(payload.items.map(\.taskId), taskIDs).allSatisfy({ $0.0 == $0.1 }), + Set(taskIDs).count == payload.items.count, + (payload.revision == nil) == (payload.offset == nil), + payload.resolvedOffset == 0, + payload.truncated == (payload.items.count < payload.total), + payload.total == 0 || !payload.items.isEmpty + else { + return false + } + + if let revision = payload.revision, + revision.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return false + } + + let first = payload.counts.failedCheck.addingReportingOverflow( + payload.counts.failedStep + ) + guard !first.overflow else { return false } + let total = first.partialValue.addingReportingOverflow(payload.counts.blocker) + guard !total.overflow, + total.partialValue == payload.total, + let observed = observedAttentionCounts(in: payload.items), + attentionCounts(observed, fitWithin: payload.counts), + hasConsistentAttentionPrefixOrder(payload.items, reported: payload.counts) + else { + return false + } + return payload.items.count < payload.total || observed == payload.counts +} + +/// Refresh the leading rows without discarding pages the user already loaded. +/// The ranking revision proves that the tail still belongs after the refreshed +/// head; any missing or contradictory paging evidence falls back to page one. +func attentionItemsAfterHeadRefresh( + existing: [ReceiptSummary], + previous: V1AttentionPayload?, + refreshed: V1AttentionPayload +) -> [ReceiptSummary] { + guard hasConsistentAttentionHeadEnvelope(refreshed), + let previous, + hasConsistentAttentionHeadEnvelope(previous), + previous.offset != nil, + previous.resolvedOffset == 0, + refreshed.offset != nil, + refreshed.resolvedOffset == 0, + let previousRevision = previous.revision, + refreshed.revision == previousRevision, + refreshed.schema == previous.schema, + refreshed.total == previous.total, + refreshed.counts == previous.counts, + refreshed.items.count <= existing.count, + existing.count <= refreshed.total, + refreshed.items.allSatisfy({ $0.attention != nil }) + else { + return refreshed.items + } + + let existingIDs = existing.map(\.taskId) + let previousIDs = previous.items.map(\.taskId) + let refreshedIDs = refreshed.items.map(\.taskId) + guard Set(existingIDs).count == existingIDs.count, + previousIDs.count <= existingIDs.count, + Array(existingIDs.prefix(previousIDs.count)) == previousIDs, + Array(existingIDs.prefix(refreshedIDs.count)) == refreshedIDs + else { + return refreshed.items + } + + return refreshed.items + existing.dropFirst(refreshed.items.count) +} + +/// Append one server-ranked attention page only when it is a continuation of +/// the same complete projection. A daemon that ignores `offset`, changing +/// counts, or repeated task ids fails closed so Work never presents duplicate +/// rows as additional review coverage. +func mergedAttentionItems( + existing: [ReceiptSummary], + summary: V1AttentionPayload, + page: V1AttentionPayload +) -> [ReceiptSummary]? { + let summaryIDs = summary.items.map { + $0.taskId.trimmingCharacters(in: .whitespacesAndNewlines) + } + let existingIDsInOrder = existing.map { + $0.taskId.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard hasConsistentAttentionHeadEnvelope(summary), + summary.offset != nil, + summary.resolvedOffset == 0, + summary.revision != nil, + summaryIDs.count <= existingIDsInOrder.count, + Array(existingIDsInOrder.prefix(summaryIDs.count)) == summaryIDs, + Set(summaryIDs).count == summaryIDs.count, + page.schema == summary.schema, + page.total == summary.total, + page.counts == summary.counts, + page.revision != nil, + page.revision == summary.revision, + page.offset != nil, + page.resolvedOffset == existing.count, + page.total >= 0, + (1 ... 50).contains(page.limit), + page.items.count <= page.limit, + !page.truncated || !page.items.isEmpty, + page.items.allSatisfy({ $0.attention != nil }) + else { + return nil + } + + let end = page.resolvedOffset.addingReportingOverflow(page.items.count) + guard !end.overflow, + end.partialValue <= page.total, + page.truncated == (end.partialValue < page.total) + else { + return nil + } + + let existingIDs = Set(existingIDsInOrder) + let pageIDs = page.items.map { + $0.taskId.trimmingCharacters(in: .whitespacesAndNewlines) + } + let merged = existing + page.items + guard existingIDs.count == existing.count, + pageIDs.allSatisfy({ !$0.isEmpty }), + zip(page.items.map(\.taskId), pageIDs).allSatisfy({ $0.0 == $0.1 }), + Set(pageIDs).count == pageIDs.count, + existingIDs.isDisjoint(with: pageIDs), + let observed = observedAttentionCounts(in: merged), + attentionCounts(observed, fitWithin: summary.counts), + hasConsistentAttentionPrefixOrder(merged, reported: summary.counts), + end.partialValue < page.total || observed == summary.counts + else { + return nil + } + return merged +} + +func recordedTaskDisplayTitle(_ title: String?, taskId: String) -> String { + guard let title = title?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty + else { + return taskId + } + return title +} + /// The server-selected leading reason for one attention Task. The summary and /// next step are recorded evidence; a missing `next_step` deliberately remains /// nil so the UI cannot turn a generic suggestion into an agent claim. @@ -493,8 +734,8 @@ struct ReceiptAttention: Decodable { struct ReceiptSummary: Decodable, Identifiable { let taskId: String let title: String? - /// Present on the attention projection; optional for older `/v1/tasks` - /// payloads and older daemons. + /// Present on current Task and attention summaries; optional for payloads + /// served by older daemons. let project: String? /// Present only on `/v1/attention` rows. let attention: ReceiptAttention? diff --git a/apps/agentacct/Sources/agentacct/WorkPane.swift b/apps/agentacct/Sources/agentacct/WorkPane.swift index b831513..fd2657b 100644 --- a/apps/agentacct/Sources/agentacct/WorkPane.swift +++ b/apps/agentacct/Sources/agentacct/WorkPane.swift @@ -130,13 +130,18 @@ struct WorkAttentionEmptyCopy: Equatable { let title: String let detail: String - init(payload: V1AttentionPayload, query: String) { + init(payload: V1AttentionPayload, query: String, loadedCount: Int? = nil) { + let loadedCount = min( + max(0, loadedCount ?? payload.items.count), + max(0, payload.total) + ) + let hasQuery = hasWorkQuery(query) if payload.total == 0 { title = "No current review items" detail = "The complete attention projection reports no failed checks, failed steps, or unresolved blockers." - } else if !query.isEmpty, !payload.items.isEmpty { + } else if hasQuery, loadedCount > 0 { title = "No review items match this filter" - detail = "The bounded queue has \(payload.items.count) of \(payload.total) review items; adjust the filter to inspect them." + detail = "The loaded queue has \(loadedCount) of \(payload.total) review items; adjust the filter to inspect them." } else { title = "Review queue details unavailable" detail = "The complete projection reports \(payload.total) review items, but no bounded queue rows were returned. Refresh before acting." @@ -144,6 +149,32 @@ struct WorkAttentionEmptyCopy: Equatable { } } +func workReceiptFooterText( + visibleCount: Int, + loadedCount: Int, + totalCount: Int?, + query: String, + sort: WorkSort +) -> String { + let total = totalCount ?? loadedCount + let hasQuery = hasWorkQuery(query) + if total > loadedCount { + let visible = hasQuery + ? "\(visibleCount) match filter in latest \(loadedCount) loaded" + : "\(visibleCount) shown from latest \(loadedCount) loaded" + return "\(visible) · \(total) total receipts · \(sort.footerText)" + } + if hasQuery { + return "\(visibleCount) match filter · \(loadedCount) receipts loaded · \(sort.footerText)" + } + return "\(visibleCount) of \(total) receipts · \(sort.footerText)" +} + +func retainedWorkListWarning(error: String?, visibleCount: Int) -> String? { + guard visibleCount > 0, let error else { return nil } + return "Showing last loaded receipts · \(error)" +} + /// Shared ordering for the receipts table and the rail — one algorithm, so the /// two surfaces can never disagree. `.latest` is the daemon's own order /// (last_activity_at desc); `.attention` is a stable partition that keeps that @@ -160,6 +191,46 @@ func sortedReceipts(_ rows: [ReceiptSummary], by sort: WorkSort) -> [ReceiptSumm } } +func receiptMatchesWorkQuery(_ receipt: ReceiptSummary, query: String) -> Bool { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !needle.isEmpty else { return true } + return (receipt.title ?? "").lowercased().contains(needle) + || receipt.taskId.lowercased().contains(needle) + || (receiptProjectContext(receipt) ?? "").lowercased().contains(needle) + || (receipt.primaryRoot?.client ?? "").lowercased().contains(needle) +} + +func hasWorkQuery(_ query: String) -> Bool { + !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty +} + +func receiptProjectContext(_ receipt: ReceiptSummary) -> String? { + guard let project = receipt.project?.trimmingCharacters(in: .whitespacesAndNewlines), + !project.isEmpty + else { + return nil + } + return project +} + +func workTableRows( + receipts: [ReceiptSummary], + attention: [ReceiptSummary], + group: WorkGroup?, + query: String, + sort: WorkSort +) -> [ReceiptSummary] { + var rows = group == .attention ? attention : receipts + if let group, group != .attention { + rows = rows.filter { WorkGroup.forKey($0.decisionStatus.key) == group } + } + rows = rows.filter { receiptMatchesWorkQuery($0, query: query) } + // Attention is already operationally ranked across the whole store. A + // client-side cost/lifecycle sort over only loaded pages would falsify + // that order, so filtering is the only transformation allowed there. + return group == .attention ? rows : sortedReceipts(rows, by: sort) +} + // MARK: - Decision-status legend /// One-line human definitions for every decision word, mirroring the daemon's @@ -524,7 +595,7 @@ private struct WorkRecordPlaceholder: View { var body: some View { WorkRecordStatusPage( reference: taskId, - title: summary?.title ?? taskId, + title: recordedTaskDisplayTitle(summary?.title, taskId: taskId), summary: summary, showList: showList ) { @@ -542,7 +613,9 @@ private struct WorkRecordPlaceholder: View { .foregroundStyle(Theme.muted) } .accessibilityElement(children: .ignore) - .accessibilityLabel("Loading receipt for \(summary?.title ?? taskId)") + .accessibilityLabel( + "Loading receipt for \(recordedTaskDisplayTitle(summary?.title, taskId: taskId))" + ) placeholder(height: 84) HStack(alignment: .top, spacing: Space.xl) { placeholder(height: 220) @@ -577,7 +650,7 @@ private struct WorkRecordErrorPage: View { var body: some View { WorkRecordStatusPage( reference: taskId, - title: summary?.title ?? taskId, + title: recordedTaskDisplayTitle(summary?.title, taskId: taskId), summary: summary, showList: showList ) { @@ -653,27 +726,13 @@ private struct WorkTablePage: View { } private var visibleTasks: [ReceiptSummary] { - var rows: [ReceiptSummary] - if group == .attention { - // The endpoint has already classified and operationally ordered a - // bounded queue across every visible Task. Do not re-derive it - // from the latest-200 Receipt page. - rows = dashboard.attention?.items ?? [] - } else { - rows = dashboard.receiptTasks - } - if let group, group != .attention { - rows = rows.filter { WorkGroup.forKey($0.decisionStatus.key) == group } - } - if !query.isEmpty { - let needle = query.lowercased() - rows = rows.filter { - ($0.title ?? "").lowercased().contains(needle) - || $0.taskId.lowercased().contains(needle) - || ($0.primaryRoot?.client ?? "").lowercased().contains(needle) - } - } - return sortedReceipts(rows, by: sort) + workTableRows( + receipts: dashboard.receiptTasks, + attention: dashboard.attentionQueueItems, + group: group, + query: query, + sort: sort + ) } var body: some View { @@ -705,15 +764,18 @@ private struct WorkTablePage: View { .font(Type.titlePage).tracking(Type.titlePageTracking) .foregroundStyle(Theme.ink) HStack(spacing: 0) { - Text("local store · \(dashboard.totalReceiptTasks ?? dashboard.receiptTasks.count) receipts") + Text(workReceiptHeaderText) .font(Type.dataSmall).foregroundStyle(Theme.muted) // The list endpoint caps at 200 rows; when the store holds // more, the tab counts cover the loaded slice — say so. - if let total = dashboard.totalReceiptTasks, total > dashboard.receiptTasks.count { + if dashboard.hasLoadedReceiptTasks, + let total = dashboard.totalReceiptTasks, + total > dashboard.receiptTasks.count + { Text(" · latest \(dashboard.receiptTasks.count) loaded") .font(Type.dataSmall).foregroundStyle(Theme.muted) } - if let updated = dashboard.lastUpdated { + if dashboard.hasLoadedReceiptTasks, let updated = dashboard.lastUpdated { Text(" · refreshed \(dashboardFreshnessText(updated))") .font(Type.dataSmall).foregroundStyle(Theme.muted) } @@ -725,11 +787,15 @@ private struct WorkTablePage: View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: Space.xl) { let truncated = (dashboard.totalReceiptTasks ?? 0) > dashboard.receiptTasks.count - tabButton(nil, label: truncated ? "Loaded" : "All", count: dashboard.receiptTasks.count) + tabButton( + nil, + label: truncated ? "Loaded" : "All", + count: dashboard.hasLoadedReceiptTasks ? dashboard.receiptTasks.count : nil + ) ForEach(WorkGroup.allCases) { candidate in let count = candidate == .attention ? dashboard.attention?.total - : (groupCounts[candidate] ?? 0) + : (dashboard.hasLoadedReceiptTasks ? groupCounts[candidate] ?? 0 : nil) // Attention is complete across the store and can exceed // Loaded; the other lifecycle counts describe that page. if candidate != .other || (count ?? 0) > 0 { @@ -775,9 +841,9 @@ private struct WorkTablePage: View { if SnapshotMode.enabled { // ImageRenderer draws a TextField / .menu Picker as a yellow // placeholder; a snapshot shows plain stand-ins instead. - Text("Filter by task, client, or id").font(Type.caption).foregroundStyle(Theme.muted) + Text("Filter by task, project, client, or id").font(Type.caption).foregroundStyle(Theme.muted) } else { - TextField("Filter by task, client, or id", text: $query) + TextField("Filter by task, project, client, or id", text: $query) .textFieldStyle(.plain).font(Type.caption) } } @@ -789,7 +855,12 @@ private struct WorkTablePage: View { .strokeBorder(Theme.cardLine, lineWidth: Metrics.borderW) ) if SnapshotMode.enabled { - Chip(text: "sort: \(sort.rawValue)", tint: Theme.accent) + Chip( + text: group == .attention ? "server ranked" : "sort: \(sort.rawValue)", + tint: group == .attention ? Theme.coral : Theme.accent + ) + } else if group == .attention { + Chip(text: "server ranked", tint: Theme.coral) } else { Picker("Sort", selection: $selection.workSort) { ForEach(WorkSort.allCases) { Text($0.rawValue).tag($0) } @@ -813,15 +884,39 @@ private struct WorkTablePage: View { .frame(maxWidth: .infinity, alignment: .leading) } else if group == .attention, dashboard.attention == nil { HStack(spacing: Space.m) { - ProgressView().controlSize(.small).tint(Theme.muted) + if SnapshotMode.enabled { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.muted) + } else { + ProgressView().controlSize(.small).tint(Theme.muted) + } Text("Checking the complete review projection…") .font(Type.body).foregroundStyle(Theme.muted) } .padding(Space.xl) .frame(maxWidth: .infinity, alignment: .leading) + } else if group != .attention, !dashboard.hasLoadedReceiptTasks { + HStack(spacing: Space.m) { + if SnapshotMode.enabled { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.muted) + } else { + ProgressView().controlSize(.small).tint(Theme.muted) + } + Text("Loading recorded work…") + .font(Type.body).foregroundStyle(Theme.muted) + } + .padding(Space.xl) + .frame(maxWidth: .infinity, alignment: .leading) } else if visibleTasks.isEmpty { let attentionCopy = dashboard.attention.map { - WorkAttentionEmptyCopy(payload: $0, query: query) + WorkAttentionEmptyCopy( + payload: $0, + query: query, + loadedCount: dashboard.attentionQueueItems.count + ) } VStack(alignment: .leading, spacing: 4) { Text(group == .attention ? attentionCopy?.title ?? "Review status unavailable" : "No receipts match") @@ -878,23 +973,52 @@ private struct WorkTablePage: View { } private var footer: some View { - HStack(spacing: Space.m) { - Text(footerText) - .font(Type.dataSmall).foregroundStyle(Theme.muted) - if group == .attention, let error = dashboard.attentionError { + VStack(alignment: .leading, spacing: Space.s) { + HStack(spacing: Space.m) { + Text(footerText) + .font(Type.dataSmall).foregroundStyle(Theme.muted) + Spacer(minLength: Space.m) + if group == .attention, dashboard.canLoadMoreAttention { + Button { + Task { await dashboard.fetchMoreAttention() } + } label: { + if dashboard.isLoadingMoreAttention { + HStack(spacing: 6) { + ProgressView().controlSize(.small) + Text("Loading…") + } + } else { + Text(dashboard.attentionPageError == nil ? "Load more" : "Retry") + } + } + .font(Type.captionSemibold) + .buttonStyle(.bordered) + .disabled(dashboard.isLoadingMoreAttention) + .accessibilityLabel( + dashboard.attentionPageError == nil + ? "Load more review items" + : "Retry loading review items" + ) + .accessibilityHint( + dashboard.attentionPageError + ?? "Loads the next server-ranked review items" + ) + .accessibilityIdentifier("work.attention.load-more") + } + } + if group == .attention, let error = dashboard.attentionPageError { Text(error) .font(Type.dataSmall) - .foregroundStyle(Theme.coral) - .lineLimit(1) - } - Spacer() - if group == .attention, dashboard.attention?.truncated == true { - Button(dashboard.isLoadingMoreAttention ? "Loading…" : "Load more") { - Task { await dashboard.fetchMoreAttention() } - } - .buttonStyle(QuietButtonStyle()) - .disabled(dashboard.isLoadingMoreAttention) - .accessibilityIdentifier("work.attention.load-more") + .foregroundStyle(Theme.amber) + } else if group != .attention, + let warning = retainedWorkListWarning( + error: dashboard.receiptListError, + visibleCount: visibleTasks.count + ) + { + Label(warning, systemImage: "exclamationmark.triangle.fill") + .font(Type.dataSmall) + .foregroundStyle(Theme.amber) } } } @@ -903,12 +1027,43 @@ private struct WorkTablePage: View { group == .attention ? dashboard.attentionError : dashboard.receiptListError } + private var workReceiptHeaderText: String { + guard dashboard.hasLoadedReceiptTasks else { + return dashboard.receiptListError == nil + ? "local store · loading receipts" + : "local store · receipts unavailable" + } + return "local store · \(dashboard.totalReceiptTasks ?? dashboard.receiptTasks.count) receipts" + } + private var footerText: String { if group == .attention, let attention = dashboard.attention { - let scope = attention.truncated ? "bounded operational queue" : "complete queue" - return "\(visibleTasks.count) of \(attention.total) review items · \(scope)" + let loaded = dashboard.attentionQueueItems.count + let scope: String + if loaded >= attention.total { + scope = "complete queue loaded" + } else if dashboard.supportsAttentionPaging { + scope = "more available" + } else { + scope = "update agentacct to load more" + } + if !hasWorkQuery(query) { + return "\(loaded) of \(attention.total) review items loaded · \(scope)" + } + return "\(visibleTasks.count) match filter · \(loaded) of \(attention.total) loaded · \(scope)" } - return "\(visibleTasks.count) of \(dashboard.totalReceiptTasks ?? dashboard.receiptTasks.count) receipts · \(sort.footerText)" + guard dashboard.hasLoadedReceiptTasks else { + return dashboard.receiptListError == nil + ? "Loading recorded work…" + : "Receipt list unavailable · refresh to retry" + } + return workReceiptFooterText( + visibleCount: visibleTasks.count, + loadedCount: dashboard.receiptTasks.count, + totalCount: dashboard.totalReceiptTasks, + query: query, + sort: sort + ) } private var legend: some View { @@ -949,9 +1104,18 @@ private struct WorkTableRow: View { Button(action: action) { HStack(spacing: Space.l) { HStack(spacing: Space.m) { - Text(task.title ?? task.taskId) - .font(Type.rowLabel).foregroundStyle(Theme.ink) - .lineLimit(1).truncationMode(.tail) + VStack(alignment: .leading, spacing: 2) { + Text(recordedTaskDisplayTitle(task.title, taskId: task.taskId)) + .font(Type.rowLabel).foregroundStyle(Theme.ink) + .lineLimit(1).truncationMode(.tail) + if let project = receiptProjectContext(task) { + Text(project) + .font(Type.dataSmall) + .foregroundStyle(Theme.muted) + .lineLimit(1) + .truncationMode(.middle) + } + } DecisionBadge( key: task.decisionStatus.key, label: task.decisionStatus.label ?? task.decisionStatus.key, @@ -988,8 +1152,12 @@ private struct WorkTableRow: View { .focused(focusedTarget, equals: .task(task.taskId)) .accessibilityIdentifier("work.table.task.\(task.taskId)") .accessibilityLabel( - "\(task.title ?? task.taskId), \(task.decisionStatus.label ?? task.decisionStatus.key), " - + "evidence \(evidence.compactHeadline)" + [ + recordedTaskDisplayTitle(task.title, taskId: task.taskId), + receiptProjectContext(task).map { "project \($0)" }, + task.decisionStatus.label ?? task.decisionStatus.key, + "evidence \(evidence.compactHeadline)", + ].compactMap { $0 }.joined(separator: ", ") ) } @@ -1170,7 +1338,7 @@ private struct WorkRailRow: View { Button(action: action) { HStack(spacing: 0) { VStack(alignment: .leading, spacing: 5) { - Text(task.title ?? task.taskId) + Text(recordedTaskDisplayTitle(task.title, taskId: task.taskId)) .font(Face.sansFont(13, selected ? .semibold : .regular)) .foregroundStyle(selected ? Theme.ink : Theme.muted) .lineLimit(2) @@ -1317,7 +1485,7 @@ struct WorkRecordPage: View { private var titleBlock: some View { VStack(alignment: .leading, spacing: Space.s) { HStack(alignment: .center, spacing: Space.m) { - Text(receipt.title ?? receipt.taskId) + Text(recordedTaskDisplayTitle(receipt.title, taskId: receipt.taskId)) .font(Type.titlePage).tracking(Type.titlePageTracking) .foregroundStyle(Theme.ink) .lineLimit(2) diff --git a/apps/agentacct/Sources/agentacct/WorkSnapshotHarness.swift b/apps/agentacct/Sources/agentacct/WorkSnapshotHarness.swift index b89a4c9..a956bca 100644 --- a/apps/agentacct/Sources/agentacct/WorkSnapshotHarness.swift +++ b/apps/agentacct/Sources/agentacct/WorkSnapshotHarness.swift @@ -3,16 +3,22 @@ import SwiftUI enum WorkSnapshotState: String { case table case receipt + case loading case empty case listError = "list-error" + case retainedListError = "retained-list-error" + case attentionOverflow = "attention-overflow" case receiptLoading = "receipt-loading" case receiptError = "receipt-error" var storeState: SnapshotWorkStoreState { switch self { case .table, .receipt: return .populated + case .loading: return .loading + case .attentionOverflow: return .attentionOverflow case .empty: return .empty case .listError: return .listError + case .retainedListError: return .retainedListError case .receiptLoading: return .receiptLoading case .receiptError: return .receiptError } @@ -21,9 +27,12 @@ enum WorkSnapshotState: String { var selectsReceipt: Bool { switch self { case .receipt, .receiptLoading, .receiptError: return true - case .table, .empty, .listError: return false + case .table, .loading, .empty, .listError, .retainedListError, .attentionOverflow: + return false } } + + var selectsAttention: Bool { self == .attentionOverflow } } struct WorkSnapshotConfiguration { @@ -48,8 +57,11 @@ struct WorkSnapshotConfiguration { ] } let transient = [ + WorkSnapshotState.loading, WorkSnapshotState.empty, .listError, + .retainedListError, + .attentionOverflow, .receiptLoading, .receiptError, ].flatMap { state in @@ -110,6 +122,7 @@ enum WorkSnapshotRenderer { let selection = AppSelection() selection.pane = .work selection.taskId = configuration.state.selectsReceipt ? work.receipt.taskId : nil + selection.workGroup = configuration.state.selectsAttention ? .attention : nil let view = MainWindow(canSetUpOverride: true) .environmentObject(glance) diff --git a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift index 5c62467..2a57378 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardInteractionTests.swift @@ -156,6 +156,59 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertTrue(DashboardActionBrief(focus: focus).text.contains("Provenance: Machine check")) } + func testAttentionProjectionNormalizesOptionalContextAndOpenProvenance() throws { + let blankTask = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-blank-context", + "project": " ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "primary_root": { "client": " ", "client_session_id": "session-1" }, + "attention": { + "kind": "failed_check", + "summary": "Snapshot verification failed", + "source": " " + } + } + """ + ) + let blankFocus = try XCTUnwrap(DashboardAttentionItem(task: blankTask)) + + XCTAssertNil(blankFocus.project) + XCTAssertNil(blankFocus.client) + XCTAssertNil(blankFocus.sourceLabel) + XCTAssertFalse(DashboardActionBrief(focus: blankFocus).text.contains("Project:")) + XCTAssertFalse(DashboardActionBrief(focus: blankFocus).text.contains("Agent:")) + XCTAssertTrue(DashboardActionBrief(focus: blankFocus).text.contains("Provenance: Not recorded")) + + let paddedTask = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-padded-context", + "project": " agentacct-gui ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "primary_root": { "client": " codex ", "client_session_id": "session-2" }, + "attention": { + "kind": "failed_check", + "summary": "Snapshot verification failed", + "source": " custom_provider " + } + } + """ + ) + let paddedFocus = try XCTUnwrap(DashboardAttentionItem(task: paddedTask)) + + XCTAssertEqual(paddedFocus.project, "agentacct-gui") + XCTAssertEqual(paddedFocus.client, "codex") + XCTAssertEqual(paddedFocus.sourceLabel, "Custom Provider") + } + func testUnknownHandoffStateDoesNotImplyRecoveryOrContinuation() throws { let task = try decode( ReceiptSummary.self, @@ -379,7 +432,7 @@ final class DashboardInteractionTests: XCTestCase { items: [item, item], total: 2, counts: V1AttentionCounts(failedCheck: 2, failedStep: 0, blocker: 0), - snapshot: nil, + revision: "duplicate-test", offset: 0, limit: 5, truncated: false @@ -408,6 +461,7 @@ final class DashboardInteractionTests: XCTestCase { "total": 2, "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, "snapshot": "queue-headline", + "offset": 0, "limit": 1, "truncated": true } @@ -420,6 +474,23 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertFalse(presentation.dashboardStatusIsWarning) } + func testUnavailableShiftBriefDoesNotPromiseThatRefreshCanRepairEveryFailure() { + let presentation = DashboardAttentionPresentation( + payload: nil, + error: DashboardDaemonFeature.attention.upgradeMessage + ) + + XCTAssertEqual(presentation.dashboardStatus, "Unavailable") + XCTAssertEqual( + DashboardDaemonFeature.attention.upgradeMessage, + "Update agentacct, then restart its local service to enable review status." + ) + XCTAssertEqual( + DashboardDaemonFeature.ingestion.upgradeMessage, + "Update agentacct, then restart its local service to enable source status." + ) + } + func testSignalRailNeverPresentsRetainedSourceHealthAsCurrentAfterAnError() throws { let healthy = try decode( V1IngestionSnapshot.self, @@ -454,6 +525,28 @@ final class DashboardInteractionTests: XCTestCase { ) } + func testSourcesPaneAlsoLetsCurrentErrorsOutrankRetainedHealth() { + XCTAssertEqual( + SourcesHealthAvailability(hasSnapshot: true, error: "network unavailable"), + .unavailable("network unavailable") + ) + XCTAssertEqual( + SourcesHealthAvailability( + hasSnapshot: false, + error: DashboardDaemonFeature.ingestion.upgradeMessage + ), + .unavailable(DashboardDaemonFeature.ingestion.upgradeMessage) + ) + XCTAssertEqual( + SourcesHealthAvailability(hasSnapshot: true, error: nil), + .connected + ) + XCTAssertEqual( + SourcesHealthAvailability(hasSnapshot: false, error: nil), + .loading + ) + } + func testWorkAttentionEmptyCopyRequiresAnAuthoritativeZero() throws { let clear = try decode( V1AttentionPayload.self, @@ -501,12 +594,630 @@ final class DashboardInteractionTests: XCTestCase { WorkAttentionEmptyCopy(payload: filtered, query: "visual").title, "No review items match this filter" ) + XCTAssertEqual( + WorkAttentionEmptyCopy( + payload: filtered, + query: " visual ", + loadedCount: 2 + ).detail, + "The loaded queue has 2 of 2 review items; adjust the filter to inspect them." + ) XCTAssertEqual( WorkAttentionEmptyCopy(payload: inconsistent, query: "visual").title, "Review queue details unavailable" ) } + func testAttentionPagesAppendWithoutChangingCompleteCountsOrRepeatingRows() throws { + let first = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 0, "limit": 1, "truncated": true + } + """ + ) + let next = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "blocker", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "waiting for approval" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": false + } + """ + ) + let whitespaceDuplicate = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": " failed-check ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": false + } + """ + ) + let blankIdentity = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": " ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": false + } + """ + ) + let contradictoryCompletion = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "another-failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "another failure" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": false + } + """ + ) + + XCTAssertEqual(first.resolvedOffset, 0) + XCTAssertEqual( + mergedAttentionItems(existing: first.items, summary: first, page: next)?.map(\.taskId), + ["failed-check", "blocker"] + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: first), + "a daemon that ignores offset must fail closed instead of repeating page one" + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: whitespaceDuplicate), + "whitespace-equivalent task ids must not appear as additional queue coverage" + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: blankIdentity), + "a blank task id must not count as additional queue coverage" + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: contradictoryCompletion), + "a complete queue must reconcile its recorded reasons with aggregate counts" + ) + + let loaded = try XCTUnwrap( + mergedAttentionItems(existing: first.items, summary: first, page: next) + ) + XCTAssertEqual( + attentionItemsAfterHeadRefresh(existing: loaded, previous: first, refreshed: first) + .map(\.taskId), + ["failed-check", "blocker"], + "an unchanged minute refresh must preserve pages the user already loaded" + ) + } + + func testAttentionPagesRejectQueueDriftAndImpossibleContinuation() throws { + let first = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 3, + "counts": { "failed_check": 2, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 0, "limit": 1, "truncated": true + } + """ + ) + let changedQueue = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "blocker", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "waiting for approval" } + }], + "total": 3, + "counts": { "failed_check": 2, "failed_step": 0, "blocker": 1 }, + "revision": "revision-2", + "offset": 1, "limit": 1, "truncated": true + } + """ + ) + let emptyMiddlePage = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [], + "total": 3, + "counts": { "failed_check": 2, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": true + } + """ + ) + let prematureBlocker = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "blocker", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "waiting for approval" } + }], + "total": 3, + "counts": { "failed_check": 2, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 1, "limit": 1, "truncated": true + } + """ + ) + + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: changedQueue), + "a changed revision means the server-ranked queue moved between requests" + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: emptyMiddlePage), + "a truncated continuation cannot make progress with an empty page" + ) + XCTAssertNil( + mergedAttentionItems(existing: first.items, summary: first, page: prematureBlocker), + "a blocker cannot appear while a reported failure-class row remains unseen" + ) + } + + @MainActor + func testAttentionRevisionDriftInvalidatesTheDashboardHead() throws { + let head = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-1", + "offset": 0, "limit": 1, "truncated": true + } + """ + ) + let driftedPage = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "blocker", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "waiting" } + }], + "total": 2, + "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, + "revision": "revision-2", + "offset": 1, "limit": 1, "truncated": false + } + """ + ) + + let store = DashboardStore() + let generation = store.beginAttentionRequest() + store.publishAttentionHead(head, requestGeneration: generation) + store.publishAttentionPage(driftedPage, requestGeneration: generation) + + XCTAssertNil(store.attention) + XCTAssertTrue(store.attentionQueueItems.isEmpty) + XCTAssertNil(store.attentionPageError) + XCTAssertEqual( + store.attentionError, + "Review queue changed while loading. Refresh before acting on it." + ) + XCTAssertEqual( + DashboardAttentionPresentation(payload: store.attention, error: store.attentionError), + .unavailable("Review queue changed while loading. Refresh before acting on it.") + ) + } + + @MainActor + func testNewerDashboardRequestsWinWhenOlderResponsesArriveLast() throws { + let oldAttention = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", "items": [], "total": 0, + "counts": { "failed_check": 0, "failed_step": 0, "blocker": 0 }, + "revision": "old", "offset": 0, "limit": 5, "truncated": false + } + """ + ) + let currentAttention = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", "items": [], "total": 0, + "counts": { "failed_check": 0, "failed_step": 0, "blocker": 0 }, + "revision": "current", "offset": 0, "limit": 5, "truncated": false + } + """ + ) + let oldReceipts = try decode( + ReceiptTasksPayload.self, + from: """ + { "schema": "agentacct.receipt.v1", "tasks": [], "total": 0, "limit": 200, "offset": 0 } + """ + ) + let currentReceipts = try decode( + ReceiptTasksPayload.self, + from: """ + { "schema": "agentacct.receipt.v1", "tasks": [], "total": 4, "limit": 200, "offset": 0 } + """ + ) + let store = DashboardStore() + + let oldAttentionRequest = store.beginAttentionRequest() + let currentAttentionRequest = store.beginAttentionRequest() + store.publishAttentionHead( + currentAttention, + requestGeneration: currentAttentionRequest + ) + store.publishAttentionHead(oldAttention, requestGeneration: oldAttentionRequest) + XCTAssertEqual(store.attention?.revision, "current") + + let oldReceiptRequest = store.beginReceiptListRequest() + let currentReceiptRequest = store.beginReceiptListRequest() + store.publishReceiptList( + currentReceipts, + requestGeneration: currentReceiptRequest + ) + store.publishReceiptList(oldReceipts, requestGeneration: oldReceiptRequest) + store.publishReceiptListFailure( + "stale failure", + requestGeneration: oldReceiptRequest + ) + XCTAssertEqual(store.totalReceiptTasks, 4) + XCTAssertNil(store.receiptListError) + + let legacyEmptyReceipts = try decode( + ReceiptTasksPayload.self, + from: """ + { "schema": "agentacct.receipt.v1", "tasks": [] } + """ + ) + let legacyRequest = store.beginReceiptListRequest() + store.publishReceiptList(legacyEmptyReceipts, requestGeneration: legacyRequest) + XCTAssertTrue(store.hasLoadedReceiptTasks) + XCTAssertTrue(store.receiptTasks.isEmpty) + XCTAssertNil(store.totalReceiptTasks) + } + + @MainActor + func testMalformedAttentionHeadsNeverReachTheWorkQueue() throws { + let item = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + } + """ + ) + let validLegacy = V1AttentionPayload( + schema: "agentacct.v1-attention.v1", + items: [item], + total: 1, + counts: V1AttentionCounts(failedCheck: 1, failedStep: 0, blocker: 0), + revision: nil, + offset: nil, + limit: 5, + truncated: false + ) + XCTAssertTrue(hasConsistentAttentionHeadEnvelope(validLegacy)) + + let legacyTruncated = V1AttentionPayload( + schema: validLegacy.schema, + items: [item], + total: 2, + counts: V1AttentionCounts(failedCheck: 2, failedStep: 0, blocker: 0), + revision: nil, + offset: nil, + limit: 1, + truncated: true + ) + XCTAssertTrue(hasConsistentAttentionHeadEnvelope(legacyTruncated)) + let legacyStore = DashboardStore() + legacyStore.publishAttentionHead( + legacyTruncated, + requestGeneration: legacyStore.beginAttentionRequest() + ) + XCTAssertTrue(legacyStore.hasMoreAttention) + XCTAssertFalse(legacyStore.supportsAttentionPaging) + XCTAssertFalse(legacyStore.canLoadMoreAttention) + + let predecessorHead = try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", + "items": [{ + "task_id": "failed-check", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + }], + "total": 2, + "counts": { "failed_check": 2, "failed_step": 0, "blocker": 0 }, + "snapshot": "predecessor-queue", + "offset": 0, "limit": 1, "truncated": true + } + """ + ) + XCTAssertEqual(predecessorHead.revision, "predecessor-queue") + XCTAssertTrue(hasConsistentAttentionHeadEnvelope(predecessorHead)) + let predecessorStore = DashboardStore() + predecessorStore.publishAttentionHead( + predecessorHead, + requestGeneration: predecessorStore.beginAttentionRequest() + ) + XCTAssertTrue(predecessorStore.canLoadMoreAttention) + + XCTAssertThrowsError(try decode( + V1AttentionPayload.self, + from: """ + { + "schema": "agentacct.v1-attention.v1", "items": [], "total": 0, + "counts": { "failed_check": 0, "failed_step": 0, "blocker": 0 }, + "revision": "new", "snapshot": "old", + "offset": 0, "limit": 5, "truncated": false + } + """ + )) + + let whitespaceEquivalentItem = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": " failed-check ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": "snapshot failed" } + } + """ + ) + let blankSummaryItem = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "blank-summary", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "failed_check", "summary": " " } + } + """ + ) + let unknownKindItem = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "unknown-kind", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { "kind": "future_kind", "summary": "needs review" } + } + """ + ) + let blockerItem = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "blocker-kind", + "decision_status": { "key": "blocked" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "attention": { "kind": "blocker", "summary": "waiting" } + } + """ + ) + + let malformed = [ + V1AttentionPayload( + schema: validLegacy.schema, + items: [item], + total: 2, + counts: validLegacy.counts, + revision: "counts-mismatch", + offset: 0, + limit: 5, + truncated: true + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [item, item], + total: 2, + counts: V1AttentionCounts(failedCheck: 2, failedStep: 0, blocker: 0), + revision: "duplicate-ids", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [item, whitespaceEquivalentItem], + total: 2, + counts: V1AttentionCounts(failedCheck: 2, failedStep: 0, blocker: 0), + revision: "trimmed-duplicate-ids", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [whitespaceEquivalentItem], + total: 1, + counts: validLegacy.counts, + revision: "padded-id", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [blankSummaryItem], + total: 1, + counts: validLegacy.counts, + revision: "blank-summary", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [unknownKindItem], + total: 1, + counts: validLegacy.counts, + revision: "unknown-kind", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [blockerItem], + total: 1, + counts: validLegacy.counts, + revision: "kind-count-mismatch", + offset: 0, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [blockerItem], + total: 2, + counts: V1AttentionCounts(failedCheck: 1, failedStep: 0, blocker: 1), + revision: "blocker-before-failure", + offset: 0, + limit: 1, + truncated: true + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [item], + total: 1, + counts: validLegacy.counts, + revision: "nonzero-head", + offset: 1, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [item], + total: 1, + counts: validLegacy.counts, + revision: "missing-offset", + offset: nil, + limit: 5, + truncated: false + ), + V1AttentionPayload( + schema: validLegacy.schema, + items: [], + total: 1, + counts: validLegacy.counts, + revision: "empty-truncated-head", + offset: 0, + limit: 5, + truncated: true + ), + ] + + let store = DashboardStore() + for payload in malformed { + XCTAssertFalse(hasConsistentAttentionHeadEnvelope(payload)) + let generation = store.beginAttentionRequest() + store.publishAttentionHead(payload, requestGeneration: generation) + XCTAssertNil(store.attention) + XCTAssertTrue(store.attentionQueueItems.isEmpty) + XCTAssertFalse(store.hasMoreAttention) + XCTAssertEqual( + store.attentionError, + "Review status response was inconsistent. Refresh before acting on it." + ) + } + } + @MainActor func testDestinationsReplaceStaleDashboardSelection() { let cases: [(DashboardDestination, MainPane, String?, String?)] = [ @@ -558,77 +1269,6 @@ final class DashboardInteractionTests: XCTestCase { XCTAssertEqual(selection.workGroup, .attention, "Review-item back navigation should return to the queue") } - func testAttentionRequestGenerationRejectsAStaleResponse() { - var generation = LatestRequestGeneration() - let slowRefresh = generation.begin() - let dispositionRefresh = generation.begin() - - XCTAssertFalse(generation.accepts(slowRefresh)) - XCTAssertTrue(generation.accepts(dispositionRefresh)) - } - - func testAttentionPagesMergeWithoutHidingLaterItems() throws { - let first = try decode( - V1AttentionPayload.self, - from: """ - { - "schema": "agentacct.v1-attention.v1", - "items": [{ - "task_id": "task-1", - "decision_status": { "key": "finding" }, - "evidence_strength": { "key": "unchecked" }, - "cost": {}, - "attention": { "kind": "failed_check", "summary": "snapshot failed" } - }], - "total": 2, - "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, - "snapshot": "queue-v1", - "offset": 0, "limit": 1, "truncated": true - } - """ - ) - let second = try decode( - V1AttentionPayload.self, - from: """ - { - "schema": "agentacct.v1-attention.v1", - "items": [{ - "task_id": "task-2", - "decision_status": { "key": "blocked" }, - "evidence_strength": { "key": "unchecked" }, - "cost": {}, - "attention": { "kind": "blocker", "summary": "work blocked" } - }], - "total": 2, - "counts": { "failed_check": 1, "failed_step": 0, "blocker": 1 }, - "snapshot": "queue-v1", - "offset": 1, "limit": 1, "truncated": false - } - """ - ) - - let merged = mergedAttentionPages(first, second) - - XCTAssertEqual(merged.items.map(\.taskId), ["task-1", "task-2"]) - XCTAssertEqual(merged.offset, 0) - XCTAssertEqual(merged.limit, 2) - XCTAssertFalse(merged.truncated) - XCTAssertTrue(attentionPageCanAppend(first, second)) - - let changedQueue = V1AttentionPayload( - schema: second.schema, - items: second.items, - total: 3, - counts: second.counts, - snapshot: "queue-v2", - offset: second.offset, - limit: second.limit, - truncated: true - ) - XCTAssertFalse(attentionPageCanAppend(first, changedQueue)) - - } - func testRecentWorkProjectionKeepsDecisionEvidenceAndCostSeparate() throws { let task = try decode( ReceiptSummary.self, @@ -636,6 +1276,7 @@ final class DashboardInteractionTests: XCTestCase { { "task_id": "task-1", "title": "Build reusable snapshot harness", + "project": "agentacct-gui", "decision_status": { "key": "verified", "label": "Verified" }, "evidence_strength": { "key": "independently_checked", @@ -658,10 +1299,165 @@ final class DashboardInteractionTests: XCTestCase { let item = DashboardWorkItem(task: task) XCTAssertEqual(item.title, "Build reusable snapshot harness") + XCTAssertEqual(item.project, "agentacct-gui") XCTAssertEqual(item.client, "codex") XCTAssertEqual(item.outcome, "Verified") XCTAssertEqual(item.evidence, "4/4 checked") XCTAssertEqual(item.cost, "≈$4.82") + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [item], total: 1, hasLoaded: true, error: "refresh failed" + ), + .populated, + "retained rows remain useful even when the latest refresh fails" + ) + } + + func testWhitespaceTaskTitleFallsBackToRecordedIdentity() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-fallback", + "title": " ", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked", "checks_failed": 1 }, + "cost": {}, + "attention": { + "kind": "failed_check", "summary": " snapshot failed ", + "next_step": " " + } + } + """ + ) + + XCTAssertEqual( + recordedTaskDisplayTitle(task.title, taskId: task.taskId), + "task-fallback" + ) + XCTAssertEqual(DashboardWorkItem(task: task).title, "task-fallback") + let attention = try XCTUnwrap(DashboardAttentionItem(task: task)) + XCTAssertEqual(attention.title, "task-fallback") + XCTAssertEqual(attention.summary, "snapshot failed") + XCTAssertNil(attention.nextStep) + } + + func testRecentWorkLoadingFailureAndEmptyStatesStayDistinct() { + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: nil, hasLoaded: false, error: nil + ), + .loading + ) + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: nil, hasLoaded: false, error: "daemon unavailable" + ), + .unavailable("daemon unavailable") + ) + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: 0, hasLoaded: true, error: nil + ), + .empty + ) + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: 4, hasLoaded: true, error: nil + ), + .unavailable("The receipt count loaded, but no recent rows were returned.") + ) + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: 0, hasLoaded: true, error: "refresh failed" + ), + .unavailable("refresh failed") + ) + XCTAssertEqual( + DashboardRecentWorkPresentation( + items: [], total: nil, hasLoaded: true, error: nil + ), + .empty, + "a successful legacy response without total is loaded, not perpetual loading" + ) + } + + func testWorkQueryIncludesRecordedProjectContext() throws { + let task = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-1", + "title": "Review snapshots", + "project": "agentacct-gui", + "decision_status": { "key": "finding" }, + "evidence_strength": { "key": "unchecked" }, + "cost": {}, + "primary_root": { "client": "codex", "client_session_id": "session-1" } + } + """ + ) + + XCTAssertTrue(receiptMatchesWorkQuery(task, query: "agentacct-gui")) + XCTAssertEqual(receiptProjectContext(task), "agentacct-gui") + XCTAssertTrue(receiptMatchesWorkQuery(task, query: "CODEX")) + XCTAssertTrue(receiptMatchesWorkQuery(task, query: " ")) + XCTAssertFalse(hasWorkQuery(" \n\t ")) + XCTAssertFalse(receiptMatchesWorkQuery(task, query: "another-project")) + + let blankProject = try decode( + ReceiptSummary.self, + from: """ + { + "task_id": "task-2", "project": " ", + "decision_status": { "key": "reported" }, + "evidence_strength": { "key": "none" }, + "cost": { "estimated_cost_usd": 99 }, + "primary_root": { "client": "codex", "client_session_id": "session-2" } + } + """ + ) + XCTAssertNil(DashboardWorkItem(task: blankProject).project) + XCTAssertNil(receiptProjectContext(blankProject)) + XCTAssertEqual( + workTableRows( + receipts: [task, blankProject], + attention: [task, blankProject], + group: .attention, + query: "", + sort: .cost + ).map(\.taskId), + ["task-1", "task-2"], + "loaded attention pages must retain the server's whole-store ranking" + ) + XCTAssertEqual( + workTableRows( + receipts: [task, blankProject], + attention: [], + group: nil, + query: "", + sort: .cost + ).map(\.taskId), + ["task-2", "task-1"] + ) + } + + func testWorkFooterNamesLoadedSearchScopeAndRetainedErrors() { + XCTAssertEqual( + workReceiptFooterText( + visibleCount: 2, + loadedCount: 200, + totalCount: 500, + query: "dashboard", + sort: .latest + ), + "2 match filter in latest 200 loaded · 500 total receipts · most recent first" + ) + XCTAssertEqual( + retainedWorkListWarning(error: "refresh failed", visibleCount: 4), + "Showing last loaded receipts · refresh failed" + ) + XCTAssertNil(retainedWorkListWarning(error: "refresh failed", visibleCount: 0)) } func testRecentWorkCostLabelsDoNotClaimUnknownCompleteness() throws { diff --git a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift index 799986f..badf05b 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardSnapshotHarnessTests.swift @@ -18,6 +18,8 @@ final class DashboardSnapshotHarnessTests: XCTestCase { ExpectedArtifact(filename: "dashboard-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "dashboard-trust-unavailable-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "dashboard-trust-unavailable-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "dashboard-old-daemon-statusless-light.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "dashboard-old-daemon-statusless-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ] @MainActor @@ -254,6 +256,9 @@ final class DashboardSnapshotHarnessTests: XCTestCase { defer { SnapshotMode.setFixtureDate(nil) } XCTAssertEqual(Theme.resetsIn(1_000_000 + 6 * 86_400 + 13 * 3_600), "6d 13h") + XCTAssertNil(Theme.resetsIn(.greatestFiniteMagnitude)) + XCTAssertNil(Theme.resetsIn(.infinity)) + XCTAssertNil(Theme.resetsIn(.nan)) XCTAssertEqual(agoText(1_000_000 - 3_600), "1h ago") } diff --git a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift index 339fa13..72194ac 100644 --- a/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/DashboardVisualRegressionTests.swift @@ -12,6 +12,8 @@ final class DashboardVisualRegressionTests: XCTestCase { "dashboard-reference-dark.png", "dashboard-trust-unavailable-light.png", "dashboard-trust-unavailable-dark.png", + "dashboard-old-daemon-statusless-light.png", + "dashboard-old-daemon-statusless-dark.png", ] @MainActor diff --git a/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json b/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json index 99e4dbf..cf31097 100644 --- a/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json +++ b/apps/agentacct/Tests/agentacctTests/Fixtures/dashboard.json @@ -279,6 +279,8 @@ "failed_step": 0, "blocker": 1 }, + "revision": "dashboard-snapshot-fixture-v1", + "offset": 0, "limit": 5, "truncated": false, "items": [ @@ -363,6 +365,7 @@ { "task_id": "task-snapshot-harness", "title": "Build reusable snapshot harness", + "project": "agentacct-gui", "decision_status": { "key": "verified", "label": "Verified", @@ -396,6 +399,7 @@ { "task_id": "task-dashboard-hierarchy", "title": "Rethink dashboard product hierarchy", + "project": "agentacct-gui", "decision_status": { "key": "reported", "label": "Agent reported", @@ -430,6 +434,7 @@ { "task_id": "task-provider-calibration", "title": "Resolve provider calibration", + "project": "provider-integration", "decision_status": { "key": "blocked", "label": "Blocked", @@ -454,6 +459,7 @@ { "task_id": "task-visual-regression", "title": "Review dashboard visual regression", + "project": "agentacct-gui", "decision_status": { "key": "finding", "label": "Open finding", diff --git a/apps/agentacct/Tests/agentacctTests/MenuPresentationTests.swift b/apps/agentacct/Tests/agentacctTests/MenuPresentationTests.swift index 25b5f57..4d42242 100644 --- a/apps/agentacct/Tests/agentacctTests/MenuPresentationTests.swift +++ b/apps/agentacct/Tests/agentacctTests/MenuPresentationTests.swift @@ -69,6 +69,26 @@ final class MenuPresentationTests: XCTestCase { XCTAssertNotEqual(presentation.primary?.id, presentation.secondary.first?.id) } + func testPercentageTextRejectsValuesThatCannotBeDisplayedSafely() { + func item(_ usedPercent: Double?) -> MenuLimitItem { + MenuLimitItem( + id: "test", + client: "codex", + clientLabel: "Codex", + windowLabel: "7-day limit", + usedPercent: usedPercent, + resetText: nil + ) + } + + XCTAssertEqual(item(nil).percentageText, "Not reported") + XCTAssertEqual(item(0.5).percentageText, "<1%") + XCTAssertEqual(item(.greatestFiniteMagnitude).percentageText, "Invalid percentage") + XCTAssertEqual(item(.infinity).percentageText, "Invalid percentage") + XCTAssertEqual(item(.nan).percentageText, "Invalid percentage") + XCTAssertEqual(item(-1).percentageText, "Invalid percentage") + } + func testUsageUsesWindowDurationAndNamesMissingEvidence() throws { let fixture = try DashboardSnapshotFixture.load(from: fixtureURL()) let sparse = try XCTUnwrap(fixture.menuSparseGlance) diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-dark.png new file mode 100644 index 0000000..4019340 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-light.png new file mode 100644 index 0000000..e54ab0e Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-old-daemon-statusless-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png index 3f2b5fc..2a67007 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png index 7db00fb..504f018 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-dark.png index 32b3756..804f2d4 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-light.png index 3da5b03..f3b6de0 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/dashboard-trust-unavailable-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-dark.png new file mode 100644 index 0000000..26f5d3a Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-light.png new file mode 100644 index 0000000..315e26b Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-attention-overflow-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-dark.png index 39c7af2..74e91ce 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-light.png index 780bfa3..07d4769 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-empty-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-dark.png index 2d8e28f..25f11ad 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-light.png index fc62ea5..cbe1393 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-list-error-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-dark.png new file mode 100644 index 0000000..1e92583 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-light.png new file mode 100644 index 0000000..bcee1ea Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-loading-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-dark.png new file mode 100644 index 0000000..b958fae Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-light.png new file mode 100644 index 0000000..0fb0858 Binary files /dev/null and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-retained-list-error-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-dark.png index 5c14f2c..238fc6b 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-light.png index 1705e24..79ba714 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-minimum-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-dark.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-dark.png index 4c3882a..4e72889 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-dark.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-dark.png differ diff --git a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-light.png b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-light.png index 721ff35..966ab84 100644 Binary files a/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-light.png and b/apps/agentacct/Tests/agentacctTests/ReferenceImages/macos-26.6-25G72-xcode-26.6-17F113-arm64-2x/work-table-reference-light.png differ diff --git a/apps/agentacct/Tests/agentacctTests/UsageCapacityTests.swift b/apps/agentacct/Tests/agentacctTests/UsageCapacityTests.swift index c057033..b334ad1 100644 --- a/apps/agentacct/Tests/agentacctTests/UsageCapacityTests.swift +++ b/apps/agentacct/Tests/agentacctTests/UsageCapacityTests.swift @@ -171,6 +171,26 @@ final class UsageCapacityTests: XCTestCase { ) } + func testResetCopyRejectsNonfiniteAndOutOfRangeTimes() { + for resetsAt in [ + Double.nan, + Double.infinity, + -Double.infinity, + Double.greatestFiniteMagnitude, + ] { + let window = LimitWindow( + kind: "7d", + usedPercent: 50, + windowMinutes: nil, + resetsAt: resetsAt + ) + XCTAssertEqual( + LimitWindowPresentation(window: window, stale: false).resetText, + "Invalid reset time" + ) + } + } + func testAccessibilitySummaryDistinguishesMissingValuesFromObservedZero() throws { let usage = try decode([UsageBucket].self, from: """ [ diff --git a/apps/agentacct/Tests/agentacctTests/WorkSnapshotHarnessTests.swift b/apps/agentacct/Tests/agentacctTests/WorkSnapshotHarnessTests.swift index 15bd461..52311f2 100644 --- a/apps/agentacct/Tests/agentacctTests/WorkSnapshotHarnessTests.swift +++ b/apps/agentacct/Tests/agentacctTests/WorkSnapshotHarnessTests.swift @@ -20,10 +20,16 @@ final class WorkSnapshotHarnessTests: XCTestCase { ExpectedArtifact(filename: "work-receipt-minimum-dark.png", pixelsWide: 1920, pixelsHigh: 1120), ExpectedArtifact(filename: "work-receipt-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-receipt-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-loading-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-loading-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-empty-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-empty-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-list-error-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-list-error-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-retained-list-error-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-retained-list-error-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-attention-overflow-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), + ExpectedArtifact(filename: "work-attention-overflow-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-receipt-loading-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-receipt-loading-reference-dark.png", pixelsWide: 2240, pixelsHigh: 1600), ExpectedArtifact(filename: "work-receipt-error-reference-light.png", pixelsWide: 2240, pixelsHigh: 1600), @@ -65,9 +71,14 @@ final class WorkSnapshotHarnessTests: XCTestCase { @MainActor func testTransientTableStatesKeepAttentionEvidenceConsistent() throws { let fixture = try DashboardSnapshotFixture.load(from: fixtureURL()) + let loading = DashboardStore(preloaded: fixture, workState: .loading) let empty = DashboardStore(preloaded: fixture, workState: .empty) let listError = DashboardStore(preloaded: fixture, workState: .listError) + XCTAssertFalse(loading.hasLoadedReceiptTasks) + XCTAssertTrue(loading.receiptTasks.isEmpty) + XCTAssertNil(loading.receiptListError) + XCTAssertEqual(empty.totalReceiptTasks, 0) XCTAssertEqual(empty.attention?.total, 0) XCTAssertEqual(empty.attention?.items.count, 0) diff --git a/apps/agentacct/Tests/agentacctTests/WorkVisualRegressionTests.swift b/apps/agentacct/Tests/agentacctTests/WorkVisualRegressionTests.swift index 566f240..6522342 100644 --- a/apps/agentacct/Tests/agentacctTests/WorkVisualRegressionTests.swift +++ b/apps/agentacct/Tests/agentacctTests/WorkVisualRegressionTests.swift @@ -14,10 +14,16 @@ final class WorkVisualRegressionTests: XCTestCase { "work-receipt-minimum-dark.png", "work-receipt-reference-light.png", "work-receipt-reference-dark.png", + "work-loading-reference-light.png", + "work-loading-reference-dark.png", "work-empty-reference-light.png", "work-empty-reference-dark.png", "work-list-error-reference-light.png", "work-list-error-reference-dark.png", + "work-retained-list-error-reference-light.png", + "work-retained-list-error-reference-dark.png", + "work-attention-overflow-reference-light.png", + "work-attention-overflow-reference-dark.png", "work-receipt-loading-reference-light.png", "work-receipt-loading-reference-dark.png", "work-receipt-error-reference-light.png", diff --git a/docs/task-control-plane.md b/docs/task-control-plane.md index ddab18b..cfbee6c 100644 --- a/docs/task-control-plane.md +++ b/docs/task-control-plane.md @@ -84,11 +84,13 @@ recorded failed steps lead unresolved blockers—even when a Task has both a failure and a blocker—and recency orders Tasks within each class. This is review ordering, not a claim about business priority. Each row includes its recorded reason and next step when available; agentacct does not invent a recovery action -for a failed check. Every page carries the same opaque `snapshot` digest while -that classification is stable; clients restart paging if the digest changes. -The complete classification and ordering are cached with the -parent Task projection, so repeated dashboard polls rebuild them only when that -projection changes. +for a failed check. Every page carries the same opaque ranking `revision` and +complete counts while that classification is stable; the additive `snapshot` +field carries the same value for immediate-predecessor desktop clients. Clients +restart paging if the revision changes. The complete classification and ordering are cached with +the parent Task projection, so repeated dashboard polls rebuild them only when +that projection changes. Clients load later ranked rows with `offset` and reject +pages from a changed projection instead of skipping or repeating work. ## Owned execution boundary diff --git a/src/agentacct/api.py b/src/agentacct/api.py index ea4ddd4..75c7373 100644 --- a/src/agentacct/api.py +++ b/src/agentacct/api.py @@ -3034,7 +3034,7 @@ def _v1_attention_candidates( return cached[2] if cached is not None and cached[1] == attention_fingerprint: - cached_candidates, _, cached_counts, _ = cached[2] + cached_candidates, _, cached_counts, cached_revision = cached[2] tasks_by_id = { str(task.get("public_task_id")): task for task in tasks @@ -3048,7 +3048,7 @@ def _v1_attention_candidates( candidates, latest_store_activity(tasks), cached_counts, - attention_fingerprint, + cached_revision, ) v1_attention_projection_cache["value"] = ( projection, @@ -3151,7 +3151,7 @@ def v1_attention( _require_v1_token(request) projection = _v1_task_projection() - candidates, latest, counts, snapshot = _v1_attention_candidates(projection) + candidates, latest, counts, revision = _v1_attention_candidates(projection) selected = candidates[offset : offset + limit] items = [] for _, _, task_id, task, reason in selected: @@ -3170,7 +3170,10 @@ def v1_attention( "items": items, "total": total, "counts": counts, - "snapshot": snapshot, + "revision": revision, + # Immediate predecessor builds called this identity `snapshot`. + # Keep the additive alias while desktop clients roll forward. + "snapshot": revision, "offset": offset, "limit": limit, "truncated": offset + len(items) < total, @@ -3371,7 +3374,7 @@ def index() -> dict[str, Any]: "/v1/session?client=&session_id= (bearer token from the store's local-api.json)", "/v1/plan (bearer token from the store's local-api.json)", "/v1/tasks (bearer token from the store's local-api.json)", - "/v1/attention?limit= (bearer token from the store's local-api.json)", + "/v1/attention?limit=&offset= (bearer token from the store's local-api.json)", "/v1/receipt?task= (bearer token from the store's local-api.json)", "/v1/ingestion (bearer token from the store's local-api.json)", ], diff --git a/tests/test_receipt_api.py b/tests/test_receipt_api.py index 08178c8..6ffa919 100644 --- a/tests/test_receipt_api.py +++ b/tests/test_receipt_api.py @@ -150,11 +150,16 @@ def _record_failed_check(service: SentinelService, *, session_id: str, section_i def _record_blocked_section( - service: SentinelService, *, session_id: str, section_id: str, at: float + service: SentinelService, + *, + session_id: str, + section_id: str, + at: float, + event_suffix: str = "", ) -> None: service.record_event( { - "event_id": f"evt_section_{session_id}_{section_id}_blocked", + "event_id": f"evt_section_{session_id}_{section_id}_blocked{event_suffix}", "created_at": at, "source": "claude-code", "event_type": "section_blocked", @@ -281,9 +286,9 @@ def test_attention_empty_state_and_query_bounds(tmp_path: Path) -> None: client = _app(tmp_path) payload = client.get("/v1/attention", headers=_auth()).json() - snapshot = payload.pop("snapshot") - assert len(snapshot) == 64 - assert all(character in "0123456789abcdef" for character in snapshot) + revision = payload.pop("revision") + assert isinstance(revision, str) and revision + assert payload.pop("snapshot") == revision assert payload == { "schema": V1_ATTENTION_SCHEMA_VERSION, "items": [], @@ -343,14 +348,24 @@ def counted_build_attention_reason(task, **kwargs): attention = client.get("/v1/attention", headers=_auth(), params={"limit": 1}).json() assert set(attention) == { - "schema", "items", "total", "counts", "snapshot", "offset", "limit", "truncated" + "schema", + "items", + "total", + "counts", + "revision", + "snapshot", + "offset", + "limit", + "truncated", } assert attention["schema"] == V1_ATTENTION_SCHEMA_VERSION assert attention["total"] == 2 assert attention["counts"] == {"failed_check": 1, "failed_step": 0, "blocker": 1} + assert isinstance(attention["revision"], str) and attention["revision"] + assert attention["snapshot"] == attention["revision"] + assert attention["offset"] == 0 assert attention["limit"] == 1 assert attention["truncated"] is True - assert attention["offset"] == 0 assert len(attention["items"]) == 1 leading = attention["items"][0] assert leading["primary_root"]["client_session_id"] == "finding" @@ -371,9 +386,13 @@ def counted_build_attention_reason(task, **kwargs): headers=_auth(), params={"limit": 1, "offset": 1}, ).json() + assert next_attention["total"] == attention["total"] + assert next_attention["counts"] == attention["counts"] + assert next_attention["revision"] == attention["revision"] + assert next_attention["snapshot"] == attention["snapshot"] assert next_attention["offset"] == 1 + assert next_attention["limit"] == 1 assert next_attention["truncated"] is False - assert next_attention["snapshot"] == attention["snapshot"] assert [row["primary_root"]["client_session_id"] for row in next_attention["items"]] == [ "blocked" ] @@ -407,7 +426,7 @@ def counted_build_attention_reason(task, **kwargs): params={"limit": 5}, ).json() assert after_parent_ttl["total"] == 2 - assert after_parent_ttl["snapshot"] == attention["snapshot"] + assert after_parent_ttl["revision"] == attention["revision"] assert len(classification_calls) == 3 _record_usage(service, session_id="new-finding", at=400.0) @@ -430,12 +449,101 @@ def counted_build_attention_reason(task, **kwargs): params={"limit": 5}, ).json() assert changed_attention["total"] == 3 - assert changed_attention["snapshot"] != attention["snapshot"] + assert changed_attention["revision"] != attention["revision"] # Changed content invalidates the index and classifies all four current # Tasks; the clean Task still does not enter the three-item queue. assert len(classification_calls) == 7 +def test_attention_pages_reach_every_ranked_item_without_overlap(tmp_path: Path) -> None: + service = SentinelService(tmp_path) + for index in range(7): + session_id = f"blocked-{index}" + at = 100.0 + index * 10 + _record_usage(service, session_id=session_id, at=at) + _record_section( + service, + session_id=session_id, + section_id=f"sec-{index}", + status="started", + at=at + 1, + ) + _record_blocked_section( + service, + session_id=session_id, + section_id=f"sec-{index}", + at=at + 2, + ) + + client = _app(tmp_path) + complete = client.get( + "/v1/attention", headers=_auth(), params={"limit": 50} + ).json() + first = client.get( + "/v1/attention", headers=_auth(), params={"limit": 5, "offset": 0} + ).json() + second = client.get( + "/v1/attention", headers=_auth(), params={"limit": 5, "offset": 5} + ).json() + + complete_ids = [row["task_id"] for row in complete["items"]] + paged_ids = [row["task_id"] for row in first["items"] + second["items"]] + assert complete["total"] == 7 + assert first["offset"] == 0 and first["truncated"] is True + assert second["offset"] == 5 and second["truncated"] is False + assert first["revision"] == second["revision"] == complete["revision"] + assert len(set(paged_ids)) == 7 + assert paged_ids == complete_ids + + for offset in (7, 8): + beyond = client.get( + "/v1/attention", headers=_auth(), params={"limit": 5, "offset": offset} + ).json() + assert beyond["items"] == [] + assert beyond["offset"] == offset + assert beyond["truncated"] is False + + +def test_attention_revision_changes_when_same_count_queue_reorders(tmp_path: Path) -> None: + service = SentinelService(tmp_path) + for index in range(2): + session_id = f"blocked-{index}" + at = 100.0 + index * 10 + _record_usage(service, session_id=session_id, at=at) + _record_section( + service, + session_id=session_id, + section_id=f"sec-{index}", + status="started", + at=at + 1, + ) + _record_blocked_section( + service, + session_id=session_id, + section_id=f"sec-{index}", + at=at + 2, + ) + + client = _app(tmp_path) + before = client.get("/v1/attention", headers=_auth()).json() + _record_blocked_section( + service, + session_id="blocked-0", + section_id="sec-0", + at=500.0, + event_suffix="_newer", + ) + after = client.get("/v1/attention", headers=_auth()).json() + + assert before["total"] == after["total"] == 2 + assert before["counts"] == after["counts"] + assert {row["task_id"] for row in before["items"]} == { + row["task_id"] for row in after["items"] + } + assert before["items"][0]["task_id"] != after["items"][0]["task_id"] + assert before["revision"] != after["revision"] + + def test_tasks_list_and_receipt_detail_for_an_observed_task(tmp_path: Path) -> None: service = SentinelService(tmp_path) _record_usage(service, session_id="s1", at=100.0)