From ad4abc86e2c6b50d530b6f72f6bea320266b1b22 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:14:59 -0700 Subject: [PATCH 1/3] fix: attribute task model catalog results --- .../DiagnosticEventPresentation.swift | 46 ++++++-- .../CMUXMobileCore/DiagnosticLog.swift | 21 ++++ .../CMUXMobileCore/DiagnosticTaxonomy.swift | 19 ++++ .../MobileNetworkOutcomeReporter.swift | 42 +++++++- .../MobileNetworkOutcomeReporterTests.swift | 28 +++++ .../MobileShellComposite+AppDiagnostics.swift | 33 ++++++ .../MobileShellComposite+TaskModels.swift | 9 +- .../observability/mobileNetworkOutcome.ts | 101 +++++++++++++++++- ...mobile-network-observability-route.test.ts | 23 ++++ 9 files changed, 311 insertions(+), 11 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift index e369f5c7fb73..bcaa3188fd2d 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -189,10 +189,10 @@ public struct DiagnosticEventPresentation: Sendable { fields.append(decodeA(a, code: event.code)) } if let b = event.b { - fields.append(decodeB(b, code: event.code)) + fields.append(decodeB(b, event: event)) } if let ms = event.ms { - fields.append(decodeMilliseconds(ms, code: event.code)) + fields.append(decodeMilliseconds(ms, event: event)) } if let c = event.c { fields.append(decodeC(c, event: event)) @@ -480,11 +480,11 @@ public struct DiagnosticEventPresentation: Sendable { } } - private func decodeB(_ raw: Int, code: DiagnosticEventCode) -> Field { - if Self.codesWithFailureB.contains(code) { + private func decodeB(_ raw: Int, event: DiagnosticEvent) -> Field { + if Self.codesWithFailureB.contains(event.code) { return Field(key: "failure", value: failureName(raw)) } - switch code { + switch event.code { case .recoveryStarted: return Field(key: "trigger", value: recoveryTriggerName(raw)) case .transportSessionLifecycle: @@ -507,6 +507,12 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "private_fallback_paths", value: String(raw)) case .discoverySucceeded: return Field(key: "bindings", value: String(raw)) + case .appFeatureAction: + if event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue, + let provider = DiagnosticTaskModelProvider(rawValue: raw) { + return Field(key: "provider", value: taskModelProviderName(provider)) + } + return Field(key: "detail_2", value: String(raw)) case .transportPrivateAddressJoin: return Field(key: "configured_addresses", value: String(raw)) case .transportLANDiscovery: @@ -532,9 +538,9 @@ public struct DiagnosticEventPresentation: Sendable { private func decodeMilliseconds( _ raw: UInt32, - code: DiagnosticEventCode + event: DiagnosticEvent ) -> Field { - switch code { + switch event.code { case .renderGridLag: return Field(key: "lag", value: duration(raw)) case .livenessResubscribe: @@ -543,6 +549,11 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "retry_delay", value: duration(raw)) case .transportCloseAttribution: return Field(key: "application_error_code", value: String(raw)) + case .appFeatureAction: + if event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue { + return Field(key: "effort_count", value: String(raw)) + } + return Field(key: "duration", value: duration(raw)) case .composerActiveTransition, .composerKeyboardToggleWhilePresented: return Field(key: "keyboard_height", value: pointCount(Int(raw))) default: @@ -604,6 +615,10 @@ public struct DiagnosticEventPresentation: Sendable { return Field(key: "method", value: connectionMethodName(raw)) case .foregroundTransportSelected: return Field(key: "transport", value: transportName(raw)) + case .taskModelListResultObserved: + if let source = DiagnosticTaskModelSource(rawValue: raw) { + return Field(key: "source", value: taskModelSourceName(source)) + } default: if Self.appEventKindsWithValuePayload.contains(kind) { return Field(key: "value", value: String(raw)) @@ -681,6 +696,23 @@ public struct DiagnosticEventPresentation: Sendable { ?? unknownPayloadName(raw) } + private func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String { + switch provider { + case .claude: "claude" + case .codex: "codex" + case .openCode: "opencode" + } + } + + private func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String { + switch source { + case .discovered: "discovered" + case .backend: "backend" + case .augmented: "augmented" + case .fallback: "fallback" + } + } + private func unknownPayloadName(_ raw: Int) -> String { localized( "diagnostics.unknown.payload", diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift index d9fc8045c83c..e30438aa71f6 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticLog.swift @@ -206,6 +206,27 @@ public final class DiagnosticLog: Sendable { )) } + /// Records the source and effort metadata of one visible task model result. + /// + /// The fixed integer slots avoid exporting provider names, model IDs, or + /// command output. The event's `b` slot is the provider, `c` is the source, + /// and `ms` is the total number of efforts exposed by the result. + public nonisolated func recordTaskModelResult( + correlationID: String?, + provider: DiagnosticTaskModelProvider, + source: DiagnosticTaskModelSource, + effortCount: Int + ) { + record(DiagnosticEvent( + .appFeatureAction, + surface: correlation.handle(for: correlationID), + ms: UInt32(clamping: max(0, effortCount)), + a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue, + b: provider.rawValue, + c: source.rawValue + )) + } + /// Records one terminal-operation phase with a per-minute admission cap. /// The ring and AppLog still provide their existing bounded retention. public nonisolated func recordTerminalTrace( diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift index c30ff500c466..f0695567d6cf 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticTaxonomy.swift @@ -872,6 +872,10 @@ public enum DiagnosticAppEventKind: Int, Sendable, Codable, CaseIterable { case authBootstrapCompleted = 664 /// A DEBUG launch attach route was admitted by the startup coordinator. case dogfoodAttachStarted = 665 + /// A visible task model result records its provider, source, and total + /// effort count for root-cause queries. `b` is the provider, + /// `c` is the source, and `ms` is the effort count. + case taskModelListResultObserved = 666 } /// The user's configured connection method, mirrored from the settings picker @@ -882,6 +886,21 @@ public enum DiagnosticConnectionMethod: Int, Sendable, Codable, CaseIterable { case direct = 2 } +/// Provider identifier carried by task model discovery diagnostics. +public enum DiagnosticTaskModelProvider: Int, Sendable, Codable, CaseIterable { + case claude = 1 + case codex = 2 + case openCode = 3 +} + +/// Source identifier carried by task model discovery diagnostics. +public enum DiagnosticTaskModelSource: Int, Sendable, Codable, CaseIterable { + case discovered = 1 + case backend = 2 + case augmented = 3 + case fallback = 4 +} + /// High-level lifecycle state for one phone-controlled Simulator stream. /// /// Values intentionally omit panel UUIDs, device names, workspace titles, and diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift index 2474bcf33579..708595420e0d 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift @@ -1,6 +1,23 @@ public import CMUXMobileCore internal import Foundation +private func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String { + switch provider { + case .claude: "claude" + case .codex: "codex" + case .openCode: "opencode" + } +} + +private func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String { + switch source { + case .discovered: "discovered" + case .backend: "backend" + case .augmented: "augmented" + case .fallback: "fallback" + } +} + /// Reports bounded connectivity and task model discovery outcomes. /// /// Starts stay local. Only terminal outcomes reach Axiom, which keeps the @@ -12,6 +29,8 @@ public final class MobileNetworkOutcomeReporter: Sendable { public static let eventName = "ios_connectivity_latency" /// The Axiom event name for task model discovery diagnostics. public static let taskModelEventName = "ios_task_model_discovery" + /// The Axiom event name for visible task model result metadata. + public static let taskModelResultEventName = "ios_task_model_result" private enum Phase: String, Hashable, Sendable { case endpointStart = "endpoint_start" @@ -101,7 +120,10 @@ public final class MobileNetworkOutcomeReporter: Sendable { if event.code == .appFeatureAction, let kind = event.a.flatMap(DiagnosticAppEventKind.init(rawValue:)), let properties = Self.taskModelProperties(for: kind, event: event) { - emitter.capture(Self.taskModelEventName, properties) + let eventName = kind == .taskModelListResultObserved + ? Self.taskModelResultEventName + : Self.taskModelEventName + emitter.capture(eventName, properties) return } guard Self.mayObserve(event.code) else { return } @@ -122,6 +144,24 @@ public final class MobileNetworkOutcomeReporter: Sendable { for kind: DiagnosticAppEventKind, event: DiagnosticEvent ) -> [String: AnalyticsValue]? { + if kind == .taskModelListResultObserved { + guard let provider = event.b.flatMap(DiagnosticTaskModelProvider.init(rawValue:)), + let source = event.c.flatMap(DiagnosticTaskModelSource.init(rawValue:)) else { + return nil + } + var properties: [String: AnalyticsValue] = [ + "operation": .string("model_list"), + "outcome": .string("observed"), + "duration_ms": .int(0), + "provider": .string(taskModelProviderName(provider)), + "source": .string(taskModelSourceName(source)), + "effort_count": .int(Int(event.ms ?? 0)), + ] + if let surface = event.surface { + properties["correlation_id"] = .int(Int(surface)) + } + return properties + } let outcome: String let phase: String? switch kind { diff --git a/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/MobileNetworkOutcomeReporterTests.swift b/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/MobileNetworkOutcomeReporterTests.swift index 46ebdadc3fb7..bf8215cd37f1 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/MobileNetworkOutcomeReporterTests.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Tests/CmuxMobileAnalyticsTests/MobileNetworkOutcomeReporterTests.swift @@ -68,6 +68,34 @@ private struct NetworkOutcomeTestConsent: AnalyticsConsentProviding { #expect(event?.properties["failure"] == .string("hostUnreachable")) } + @Test func taskModelResultEmitsProviderSourceAndEffortCount() async { + let uploader = RecordingAnalyticsUploader() + let emitter = AnalyticsEmitter( + uploader: uploader, + consent: NetworkOutcomeTestConsent(isTelemetryEnabled: true), + anonymousID: "local-install" + ) + let reporter = MobileNetworkOutcomeReporter(emitter: emitter) + + reporter.ingest(DiagnosticEvent( + .appFeatureAction, + surface: 42, + ms: 6, + a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue, + b: DiagnosticTaskModelProvider.codex.rawValue, + c: DiagnosticTaskModelSource.discovered.rawValue + )) + await reporter.flush() + + let event = await uploader.uploadedEvents.first + #expect(event?.name == MobileNetworkOutcomeReporter.taskModelResultEventName) + #expect(event?.properties["operation"] == .string("model_list")) + #expect(event?.properties["provider"] == .string("codex")) + #expect(event?.properties["source"] == .string("discovered")) + #expect(event?.properties["effort_count"] == .int(6)) + #expect(event?.properties["correlation_id"] == .int(42)) + } + @Test func transportDialCompletionEmitsLatencyOnly() async { let uploader = RecordingAnalyticsUploader() let emitter = AnalyticsEmitter( diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift index b0e98b058685..c4da3e035111 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AppDiagnostics.swift @@ -1,7 +1,40 @@ public import CMUXMobileCore public import Foundation +public import CmuxMobileShellModel + extension MobileShellComposite { + /// Records the provider/source metadata for one result made visible to the + /// task composer. Counts are aggregate metadata only, never model IDs. + public func recordTaskModelResult( + provider: MobileTaskAgentProvider, + correlationID: String?, + result: MobileTaskModelListResult + ) { + let diagnosticProvider: DiagnosticTaskModelProvider = switch provider { + case .claude: .claude + case .codex: .codex + case .openCode: .openCode + } + let diagnosticSource: DiagnosticTaskModelSource = switch result.source { + case .discovered: .discovered + case .backend: .backend + case .augmented: .augmented + case .fallback: .fallback + } + var seenModelIDs = Set() + let effortCount = result.models.reduce(into: 0) { total, model in + guard seenModelIDs.insert(model.id).inserted else { return } + total += model.efforts.count + } + (result.defaultModel.flatMap { seenModelIDs.insert($0.id).inserted ? $0.efforts.count : nil } ?? 0) + diagnosticLog?.recordTaskModelResult( + correlationID: correlationID, + provider: diagnosticProvider, + source: diagnosticSource, + effortCount: effortCount + ) + } + /// Emits one privacy-safe product event through the app-wide diagnostic spine. /// /// `correlationID` is reduced to a process-local integer before admission. diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift index 387d0a677930..ba73d9fb3f60 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TaskModels.swift @@ -419,7 +419,14 @@ extension MobileShellComposite { ) } }, - didUpdate: didUpdate + didUpdate: { [weak self] result in + self?.recordTaskModelResult( + provider: provider, + correlationID: macDeviceID, + result: result + ) + didUpdate?(result) + } ) let result = discoveredTaskModelResult( provider: provider, diff --git a/web/services/observability/mobileNetworkOutcome.ts b/web/services/observability/mobileNetworkOutcome.ts index 9a6dc36c785e..ea065693b256 100644 --- a/web/services/observability/mobileNetworkOutcome.ts +++ b/web/services/observability/mobileNetworkOutcome.ts @@ -7,6 +7,7 @@ export const MAX_MOBILE_NETWORK_OUTCOME_BATCH_EVENTS = 100; const EVENT_NAME = "ios_connectivity_latency"; const TASK_MODEL_EVENT_NAME = "ios_task_model_discovery"; +const TASK_MODEL_RESULT_EVENT_NAME = "ios_task_model_result"; const TERMINAL_WINDOW_EVENT_NAME = "ios_terminal_latency_window"; const TERMINAL_ANOMALY_EVENT_NAME = "ios_terminal_latency_anomaly"; const RUNTIME_ROLE = "mobileClient"; @@ -41,6 +42,8 @@ const eventCodes = new Set([ const cancellationReasons = new Set([ "unknown", "requestCancelled", "requestTimedOut", "sessionTeardown", "sessionDeinitialized", ]); +const taskModelProviders = new Set(["claude", "codex", "opencode"]); +const taskModelSources = new Set(["discovered", "backend", "augmented", "fallback"]); const allowedPropertyKeys = new Set([ "phase", "outcome", "duration_ms", "runtime_role", "user_usable", @@ -58,6 +61,7 @@ const allowedPropertyKeys = new Set([ "duration_ms", "threshold_ms", "stage", "trace_id", "operation", "terminal_phase", "model_count", "phase", "attempt", "retry_delay_ms", "stop_reason", "correlation_id", + "provider", "source", "effort_count", ]); export type MobileNetworkOutcome = { @@ -156,7 +160,22 @@ export type MobileTaskModelDiscovery = { readonly deviceModel?: string; }; -export type MobileObservabilityEvent = MobileNetworkOutcome | MobileTerminalLatencyWindow | MobileTerminalLatencyAnomaly | MobileTaskModelDiscovery; +export type MobileTaskModelResult = { + readonly timestamp: string; + readonly provider: "claude" | "codex" | "opencode"; + readonly source: "discovered" | "backend" | "augmented" | "fallback"; + readonly effortCount: number; + readonly correlationId?: number; + readonly platform?: "ios"; + readonly clientChannel?: "dev" | "nightly" | "production" | "unknown"; + readonly appVersion?: string; + readonly buildNumber?: string; + readonly bundleIdentifier?: string; + readonly osVersion?: string; + readonly deviceModel?: string; +}; + +export type MobileObservabilityEvent = MobileNetworkOutcome | MobileTerminalLatencyWindow | MobileTerminalLatencyAnomaly | MobileTaskModelDiscovery | MobileTaskModelResult; export function parseMobileNetworkOutcome(candidate: unknown): MobileNetworkOutcome | null { if (!isRecord(candidate) || candidate.event !== EVENT_NAME || !isRecord(candidate.properties)) return null; @@ -261,7 +280,8 @@ export function parseMobileTerminalLatencyAnomaly(candidate: unknown): MobileTer } export function parseMobileObservabilityEvent(candidate: unknown): MobileObservabilityEvent | null { - return parseMobileTaskModelDiscovery(candidate) + return parseMobileTaskModelResult(candidate) + ?? parseMobileTaskModelDiscovery(candidate) ?? parseMobileNetworkOutcome(candidate) ?? parseMobileTerminalLatencyWindow(candidate) ?? parseMobileTerminalLatencyAnomaly(candidate); @@ -340,6 +360,53 @@ export function parseMobileTaskModelDiscovery(candidate: unknown): MobileTaskMod }; } +export function parseMobileTaskModelResult(candidate: unknown): MobileTaskModelResult | null { + if (!isRecord(candidate) || candidate.event !== TASK_MODEL_RESULT_EVENT_NAME || !isRecord(candidate.properties)) return null; + if (!validTimestamp(candidate.timestamp) || !validProperties(candidate.properties)) return null; + const payload = parseMobileTaskModelResultPayload(candidate.properties); + if (!payload) return null; + return { + timestamp: candidate.timestamp, + ...payload, + }; +} + +type MobileTaskModelResultPayload = Omit; + +function parseMobileTaskModelResultPayload( + properties: Record, +): MobileTaskModelResultPayload | null { + if (properties.operation !== "model_list") return null; + const provider = optionalSetValue(properties.provider, taskModelProviders); + const source = optionalSetValue(properties.source, taskModelSources); + if (typeof provider !== "string" || typeof source !== "string") return null; + const effortCount = unsignedInteger(properties.effort_count); + if (effortCount === null) return null; + const correlationId = optionalUnsignedInteger(properties.correlation_id); + if (correlationId === null) return null; + const metadata = parseMetadata(properties); + if (!metadata) return null; + return { + provider: provider as MobileTaskModelResult["provider"], + source: source as MobileTaskModelResult["source"], + effortCount, + ...(typeof correlationId === "number" ? { correlationId } : {}), + ...taskModelMetadataFields(metadata), + }; +} + +function taskModelMetadataFields(metadata: Metadata): Omit { + return { + ...(metadata.platform ? { platform: metadata.platform } : {}), + ...(metadata.clientChannel ? { clientChannel: metadata.clientChannel } : {}), + ...(metadata.appVersion ? { appVersion: metadata.appVersion } : {}), + ...(metadata.buildNumber ? { buildNumber: metadata.buildNumber } : {}), + ...(metadata.bundleIdentifier ? { bundleIdentifier: metadata.bundleIdentifier } : {}), + ...(metadata.osVersion ? { osVersion: metadata.osVersion } : {}), + ...(metadata.deviceModel ? { deviceModel: metadata.deviceModel } : {}), + }; +} + type CoreObservation = Pick; type Metadata = Pick; @@ -353,6 +420,10 @@ function validProperties(properties: Record): boolean { return !Object.keys(properties).some((key) => !allowedPropertyKeys.has(key)); } +function optionalUnsignedInteger(value: unknown): number | null | undefined { + return value === undefined ? undefined : unsignedInteger(value); +} + function parseCore(properties: Record): CoreObservation | null { if (typeof properties.phase !== "string" || !phases.has(properties.phase)) return null; if (typeof properties.outcome !== "string" || !outcomes.has(properties.outcome)) return null; @@ -511,6 +582,32 @@ export async function emitMobileObservabilityEvents( batch: readonly MobileObservabilityEvent[], ): Promise { await Promise.all(batch.map((observation) => { + if ("provider" in observation) { + return withSpan( + "cmux-mobile-network", + "cmux.mobile.task_model_result", + { + "cmux.subsystem": "mobile-network", + "cmux.runtime": "ios", + "cmux.user_id": userId, + "cmux.mobile.event": "task_model_result", + "cmux.mobile.operation": "model_list", + "cmux.mobile.provider": observation.provider, + "cmux.mobile.source": observation.source, + "cmux.mobile.effort_count": observation.effortCount, + "cmux.mobile.correlation_id": observation.correlationId, + "cmux.mobile.occurred_at": observation.timestamp, + "cmux.mobile.platform": observation.platform, + "cmux.client.channel": observation.clientChannel, + "cmux.mobile.app_version": observation.appVersion, + "cmux.mobile.build_number": observation.buildNumber, + "cmux.mobile.bundle_identifier": observation.bundleIdentifier, + "cmux.mobile.os_version": observation.osVersion, + "cmux.mobile.device_model": observation.deviceModel, + }, + () => undefined, + ); + } if ("modelCount" in observation) { return withSpan( "cmux-mobile-network", diff --git a/web/tests/mobile-network-observability-route.test.ts b/web/tests/mobile-network-observability-route.test.ts index d1bd53888672..ee9030e3ce17 100644 --- a/web/tests/mobile-network-observability-route.test.ts +++ b/web/tests/mobile-network-observability-route.test.ts @@ -161,6 +161,29 @@ describe("iOS mobile network observability route", () => { expect(invalid.status).toBe(400); }); + test("accepts task model result metadata", async () => { + const response = await POST(outcomeRequest([{ + event: "ios_task_model_result", + timestamp: "2026-09-04T12:00:00.000Z", + properties: { + operation: "model_list", + provider: "codex", + source: "discovered", + effort_count: 6, + correlation_id: 42, + platform: "ios", + }, + }])); + + expect(response.status).toBe(200); + expect(emitted[0]?.batch[0]).toMatchObject({ + provider: "codex", + source: "discovered", + effortCount: 6, + correlationId: 42, + }); + }); + test("accepts a terminal latency window with bounded percentile fields", async () => { const response = await POST(outcomeRequest([terminalWindow()])); From 950c2f8558e8868e865bcef55d2f549ca7af87d2 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:23:02 -0700 Subject: [PATCH 2/3] test: distinguish task model providers from failures --- .../DiagnosticEventPresentationTests.swift | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift index 04e00e60995e..24dd8f0fd145 100644 --- a/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift +++ b/Packages/Shared/CMUXMobileCore/Tests/CMUXMobileCoreTests/DiagnosticEventPresentationTests.swift @@ -7,6 +7,30 @@ import Testing DiagnosticEventPresentation(locale: Locale(identifier: "en")) } + @Test func taskModelResultDoesNotDecodeProviderAsFailure() { + let event = DiagnosticEvent( + .appFeatureAction, + ms: 6, + a: DiagnosticAppEventKind.taskModelListResultObserved.rawValue, + b: DiagnosticTaskModelProvider.codex.rawValue, + c: DiagnosticTaskModelSource.discovered.rawValue + ) + let presentation = englishPresentation + let fields = presentation.describe(event).fields + #expect(presentation.failureKind(of: event) == nil) + #expect(fields.contains(.init(key: "provider", value: "codex"))) + #expect(fields.contains(.init(key: "source", value: "discovered"))) + #expect(fields.contains(.init(key: "effort_count", value: "6"))) + #expect(!fields.contains { $0.key == "failure" }) + + let failed = DiagnosticEvent( + .appFeatureAction, + a: DiagnosticAppEventKind.taskModelListLoadFailed.rawValue, + b: DiagnosticFailureKind.timedOut.rawValue + ) + #expect(presentation.failureKind(of: failed) == .timedOut) + } + /// Case names are shipped telemetry vocabulary (Sentry grouping keys), so a /// rename is a breaking change this test makes visible. @Test func pinsEventCodeNames() { From cc4a7765760a74eb8074984e9e6213099dddc9f9 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:24:09 -0700 Subject: [PATCH 3/3] fix: decode task model result payloads by event kind --- .../DiagnosticEventPresentation.swift | 12 +++++-- .../MobileNetworkOutcomeReporter.swift | 34 +++++++++---------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift index bcaa3188fd2d..053158c26bf0 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/DiagnosticEventPresentation.swift @@ -227,10 +227,18 @@ public struct DiagnosticEventPresentation: Sendable { /// The failure kind carried in an event's `b` slot, when applicable. public func failureKind(of event: DiagnosticEvent) -> DiagnosticFailureKind? { - guard Self.codesWithFailureB.contains(event.code), let b = event.b else { return nil } + guard Self.carriesFailureInB(event), let b = event.b else { return nil } return DiagnosticFailureKind(rawValue: b) } + private static func carriesFailureInB(_ event: DiagnosticEvent) -> Bool { + if event.code == .appFeatureAction, + event.a == DiagnosticAppEventKind.taskModelListResultObserved.rawValue { + return false + } + return codesWithFailureB.contains(event.code) + } + /// The transport kind carried in an event's `a` slot, when applicable. public func transportKind(of event: DiagnosticEvent) -> DiagnosticTransportKind? { guard Self.codesWithTransportA.contains(event.code), let a = event.a else { return nil } @@ -481,7 +489,7 @@ public struct DiagnosticEventPresentation: Sendable { } private func decodeB(_ raw: Int, event: DiagnosticEvent) -> Field { - if Self.codesWithFailureB.contains(event.code) { + if Self.carriesFailureInB(event) { return Field(key: "failure", value: failureName(raw)) } switch event.code { diff --git a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift index 708595420e0d..c22c5595e40a 100644 --- a/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift +++ b/Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/MobileNetworkOutcomeReporter.swift @@ -1,23 +1,6 @@ public import CMUXMobileCore internal import Foundation -private func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String { - switch provider { - case .claude: "claude" - case .codex: "codex" - case .openCode: "opencode" - } -} - -private func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String { - switch source { - case .discovered: "discovered" - case .backend: "backend" - case .augmented: "augmented" - case .fallback: "fallback" - } -} - /// Reports bounded connectivity and task model discovery outcomes. /// /// Starts stay local. Only terminal outcomes reach Axiom, which keeps the @@ -138,6 +121,23 @@ public final class MobileNetworkOutcomeReporter: Sendable { await emitter.flush() } + private static func taskModelProviderName(_ provider: DiagnosticTaskModelProvider) -> String { + switch provider { + case .claude: "claude" + case .codex: "codex" + case .openCode: "opencode" + } + } + + private static func taskModelSourceName(_ source: DiagnosticTaskModelSource) -> String { + switch source { + case .discovered: "discovered" + case .backend: "backend" + case .augmented: "augmented" + case .fallback: "fallback" + } + } + /// Builds the task model discovery payload for one discovery event kind, /// or nil when the kind is not part of that group. private static func taskModelProperties(