Skip to content

Commit c5fd149

Browse files
authored
Cross-platform parity contracts: shared fixtures, client conformance suites, and REST route inventories (#11632)
## Summary Omi ships the same product on Flutter, macOS, and Windows against one backend, and the recurring failure mode is a rule that gets fixed on one platform and silently diverges on another. This PR adds the shared guard surface for that class: a platform-neutral contract fixture set at `contracts/parity/` that every client runs through its own production code, plus per-client REST route inventories so each first-party client is pinned to backend-owned OpenAPI authority the way macOS already is. Real instances this would have caught, from the merged history: - #10198 fixed local-day grouping in the app; #10980 and #10984 fixed the same class again on macOS. The day-key fixtures pin the rule once for every platform. - #11613 added a desktop route with no spec entry, which broke the macOS inventory guard for every open PR (unblocked by #11629). The new Windows and Flutter inventories close the same hole for the other two clients before it happens there. - The Windows task bucketing comment claimed it mirrors the Flutter app's grouping. It does not: the app has a separate Overdue bucket plus a 7-day aging rule for dateless tasks, macOS and Windows fold overdue into Today. The bucket fixtures pin BOTH live models per case and the README carries a divergence register, so converging is an explicit fixture edit instead of silent drift. - Two live DST bugs found by writing the label fixtures: Windows `formatDue` and the conversations `sectionLabel` computed Tomorrow and Yesterday as fixed 24h offsets, which degrade relative labels to dated ones on the two DST transition days each year. The neighboring `bucketOf` comment already warned about exactly this hazard. Both fixed to calendar day arithmetic. - One live backend wire bug found by writing the wire fixtures: a naive datetime reaching `ActionItemResponse` serialized with no UTC offset, the one wire form the clients disagree on (Dart and JS decode it as local wall time, Swift ISO8601 decoding rejects it). The response model now stamps UTC on naive values, with a red-first regression test. - One more client divergence found by the suite's own first CI run: a present-but-unparseable due_at string makes the Dart generated wire reject the WHOLE item with a FormatException, while the Windows sync mapper maps it to no-due-date and keeps the item. One corrupt timestamp in a list response breaks the app's decode path but not Windows sync. The two junk-due fixture cases now pin both behaviors as an explicit strict versus tolerant model split (divergence register entry 4) with the backend asserted on the strict side. ## What is in here - `contracts/parity/` fixtures + README: task due buckets (dual model), local day keys (offset-keyed expectations), action item wire decode agreement set, relative day labels including DST transition cases. Times are local calendar components so every platform evaluates them in its own zone with identical results. - Backend: `test_parity_contracts.py` validates the fixtures structurally and arithmetically (a malformed fixture cannot pass vacuously on every client at once), proves backend round-trip of the parseable wire vectors, rejects the junk forms clients only tolerate defensively, and pins the new naive-timestamp guard. - Windows: `bucketOf`/`formatDue` extracted from the Tasks page into `lib/taskBuckets.ts` (behavior unchanged, comment corrected), DST fixes, `mapBackendItem` exported, and two conformance suites (28 + 8 cases). - Flutter: the page-private categorization extracted to `task_categorization.dart` as a pure injectable-now function (behavior unchanged), and a conformance suite covering buckets, `conversationLocalDayKey`, and the generated wire decode. - Inventories: `test_windows_rest_inventory.py` and `test_flutter_rest_inventory.py`, siblings of the macOS one, with known gaps seeded from the current live surfaces (mirroring the macOS list's follow-up notes) and an anti-rot test that forces allowlist entries out as soon as the spec catches up. - macOS: no Swift adapter in this PR. The fixtures already encode the macOS model, and the README lists the adapter as the tracked follow-up with exact file pointers; shipping untested Swift from a non-Mac machine into the flaky Swift lane seemed worse than saying so plainly. - Two small repairs found along the way, each in its own commit: the AGENTS.md lean check compared backslash paths against forward-slash budget keys on Windows (every budgeted file misreported), and the app guide pointed at Firebase config files deleted in e81f1e8, which fails `agent-doc-references` for any PR touching agent docs. Why a fixture set instead of per-platform tests: the failing pattern is cross-platform disagreement, not single-platform regression. A shared vector file makes a behavior change reviewable as a cross-platform decision, and the backend integrity test keeps the fixtures themselves honest. ## Verification - Backend: `python -m pytest tests/unit/test_parity_contracts.py tests/unit/test_windows_rest_inventory.py tests/unit/test_flutter_rest_inventory.py -q` passes 17/17. The naive-timestamp test failed 4 cases before the validator (offsetless emission reproduced) and passes with it. 79 neighboring action item tests pass unchanged. - Windows: full vitest suite 5199 passed, 18 skipped (546 files). `npm run typecheck` clean on both configs. Mutation check: reintroducing the fixed-offset math fails exactly the 4 DST label cases; restoring the fix returns 28/28. DST cases executed locally on a US Pacific machine; they are counted skips on UTC CI and the non-DST cases always run. - Flutter: no local Flutter SDK on this machine; the suite is discovered by `app/test.sh` and is CI-verified (the first CI run passed 1031 of 1033 parity-adjacent cases and the two failures were the genuine strict-decode divergence above, now pinned). Dart formatting was aligned with CI exactly: dart 3.12.2 (the flutter 3.44.5 bundled version) with an explicit language version 3.0, verified with set-exit-if-changed. The extraction is a pure move plus a `now` parameter defaulting to the wall clock. - `make preflight`: 26 checks pass locally; `desktop-backend-candidate-probe-fixtures` fails only because the probe requires `signal.SIGALRM`, which does not exist on Windows runners (untouched by this diff; passes on Linux CI). Product invariants affected: none ## Notes for review - The `task_due_buckets.json` dual-model column is deliberate: it documents that the platforms disagree today rather than pretending one of them is authoritative. Whichever way product converges, the change is one fixture edit plus the losing platform's code. - The inventories' KNOWN_MISSING sets are seeded with the same surfaces and reasons the macOS list already tracks (unmodeled responses, unexported routers), so this PR does not regenerate the app-client spec; the anti-rot test guarantees the lists only shrink.
2 parents fdf4bcf + 864aa6f commit c5fd149

22 files changed

Lines changed: 1429 additions & 114 deletions

.github/scripts/check_agents_md_lean.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,9 @@ def main() -> int:
9696
repo = Path(__file__).resolve().parents[2]
9797

9898
errors: list[str] = []
99-
found = {str(p.relative_to(repo)) for p in discover(repo)}
99+
# as_posix keeps the keys `/`-separated on Windows too; str() would emit
100+
# backslashes there and misreport every budgeted file as both new and gone.
101+
found = {p.relative_to(repo).as_posix() for p in discover(repo)}
100102

101103
# Every AGENTS.md must carry a budget, so a new guide cannot land unbounded.
102104
for rel in sorted(found - BUDGETS.keys()):

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ These rules apply to every AI agent working in this repository. This file is **h
1717
| Desktop macOS (`desktop/macos/`) | `desktop/macos/AGENTS.md` — build/run, named bundles, self-testing, release pipeline, changelog |
1818
| Firmware (`omi/firmware/`) | `omi/firmware/AGENTS.md` — release workflow |
1919
| Product behavior | `PRODUCT.md` + `docs/product/invariants/` — locked invariants and guard tests |
20+
| A rule shared across app/macOS/Windows (buckets, day grouping, wire decode) | `contracts/parity/README.md` — shared fixtures, per-platform conformance suites, divergence register |
2021
| Fallback/fail-open branches | `docs/agents/fallback-telemetry.md` — when to call `record_fallback` |
2122
| App flows / E2E | `app/e2e/SKILL.md`, `desktop/macos/e2e/SKILL.md` |
2223
| Cursor Cloud VM (Linux x86) | `.cursor/cloud-agent-environment.md` — hermetic E2E harness, known failures |

app/AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ This handles: pub get, build_runner, gen-l10n, and flavor configuration.
2626

2727
### Firebase Config
2828
Never run `flutterfire configure` — it overwrites prod credentials. Config files:
29-
- Dev: `ios/Config/Dev/`, `android/app/src/dev/`, `lib/firebase_options_dev.dart`
30-
- Prod: `ios/Config/Prod/`, `android/app/src/prod/`, `lib/firebase_options_prod.dart`
29+
- Dev: `android/app/src/dev/`
30+
- Prod: `android/app/src/prod/`
31+
- Local emulator: `lib/firebase_options_local.dart`
3132

3233
## Native Bridge
3334

app/lib/pages/action_items/action_items_page.dart

Lines changed: 5 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,14 @@ import 'package:omi/providers/task_integration_provider.dart';
1414
import 'package:omi/services/app_review_service.dart';
1515
import 'package:omi/utils/l10n_extensions.dart';
1616
import 'package:omi/utils/other/debouncer.dart';
17+
18+
import 'task_categorization.dart';
1719
import 'widgets/action_item_form_sheet.dart';
1820
import 'widgets/action_item_shimmer_widget.dart';
1921

2022
// Re-export Goal from goals.dart for use in this file
2123
export 'package:omi/backend/http/api/goals.dart' show Goal;
2224

23-
enum TaskCategory { today, tomorrow, later, noDeadline, overdue }
24-
2525
class ActionItemsPage extends StatefulWidget {
2626
final VoidCallback? onAddGoal;
2727

@@ -345,48 +345,9 @@ class _ActionItemsPageState extends State<ActionItemsPage> with AutomaticKeepAli
345345
List<ActionItemWithMetadata> items,
346346
bool showCompleted,
347347
) {
348-
final now = DateTime.now();
349-
final startOfToday = DateTime(now.year, now.month, now.day);
350-
final startOfTomorrow = DateTime(now.year, now.month, now.day + 1);
351-
final startOfDayAfterTomorrow = DateTime(now.year, now.month, now.day + 2);
352-
final sevenDaysAgo = now.subtract(const Duration(days: 7));
353-
354-
final Map<TaskCategory, List<ActionItemWithMetadata>> categorized = {
355-
TaskCategory.today: [],
356-
TaskCategory.tomorrow: [],
357-
TaskCategory.noDeadline: [],
358-
TaskCategory.later: [],
359-
TaskCategory.overdue: [],
360-
};
361-
362-
for (var item in items) {
363-
// Skip completed items unless showing completed
364-
if (item.completed && !showCompleted) continue;
365-
if (!item.completed && showCompleted) continue;
366-
367-
if (item.dueAt == null) {
368-
// No deadline tasks older than 7 days go to overdue
369-
if (!showCompleted && item.createdAt != null && item.createdAt!.isBefore(sevenDaysAgo)) {
370-
categorized[TaskCategory.overdue]!.add(item);
371-
} else {
372-
categorized[TaskCategory.noDeadline]!.add(item);
373-
}
374-
} else {
375-
final dueDate = item.dueAt!;
376-
if (!showCompleted && dueDate.isBefore(startOfToday)) {
377-
// Due date in the past → overdue
378-
categorized[TaskCategory.overdue]!.add(item);
379-
} else if (dueDate.isBefore(startOfTomorrow)) {
380-
categorized[TaskCategory.today]!.add(item);
381-
} else if (dueDate.isBefore(startOfDayAfterTomorrow)) {
382-
categorized[TaskCategory.tomorrow]!.add(item);
383-
} else {
384-
categorized[TaskCategory.later]!.add(item);
385-
}
386-
}
387-
}
388-
389-
return categorized;
348+
// Extracted to task_categorization.dart so the bucketing rule is testable
349+
// against the shared contracts/parity fixtures.
350+
return categorizeTasks(items, showCompleted);
390351
}
391352

392353
String _getCategoryTitle(BuildContext context, TaskCategory category) {
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'package:omi/backend/schema/schema.dart';
2+
3+
/// Task list buckets for the action items page.
4+
///
5+
/// This is the app's `separate_overdue` model: past-due tasks get their own
6+
/// Overdue bucket, and tasks with no due date created more than 7 days ago age
7+
/// into Overdue too. macOS and Windows use the `fold_overdue` model instead
8+
/// (past-due folds into Today, no aging rule). Both models are pinned per case
9+
/// by the shared fixtures in `contracts/parity/task_due_buckets.json`; changing
10+
/// the rule here means editing that fixture in the same PR.
11+
enum TaskCategory { today, tomorrow, later, noDeadline, overdue }
12+
13+
/// Buckets [items] by due date relative to [now] (defaults to the wall clock;
14+
/// injectable so the parity conformance test can run the fixture vectors).
15+
///
16+
/// Extracted from the action items page state so the rule is unit-testable.
17+
/// The overdue branches only apply to the open-tasks view: in the completed
18+
/// view a past-due task shows under Today and a stale dateless one under
19+
/// No deadline.
20+
Map<TaskCategory, List<ActionItemWithMetadata>> categorizeTasks(
21+
List<ActionItemWithMetadata> items,
22+
bool showCompleted, {
23+
DateTime? now,
24+
}) {
25+
final current = now ?? DateTime.now();
26+
final startOfToday = DateTime(current.year, current.month, current.day);
27+
final startOfTomorrow = DateTime(current.year, current.month, current.day + 1);
28+
final startOfDayAfterTomorrow = DateTime(current.year, current.month, current.day + 2);
29+
final sevenDaysAgo = current.subtract(const Duration(days: 7));
30+
31+
final Map<TaskCategory, List<ActionItemWithMetadata>> categorized = {
32+
TaskCategory.today: [],
33+
TaskCategory.tomorrow: [],
34+
TaskCategory.noDeadline: [],
35+
TaskCategory.later: [],
36+
TaskCategory.overdue: [],
37+
};
38+
39+
for (var item in items) {
40+
// Skip completed items unless showing completed
41+
if (item.completed && !showCompleted) continue;
42+
if (!item.completed && showCompleted) continue;
43+
44+
if (item.dueAt == null) {
45+
// No deadline tasks older than 7 days go to overdue
46+
if (!showCompleted && item.createdAt != null && item.createdAt!.isBefore(sevenDaysAgo)) {
47+
categorized[TaskCategory.overdue]!.add(item);
48+
} else {
49+
categorized[TaskCategory.noDeadline]!.add(item);
50+
}
51+
} else {
52+
final dueDate = item.dueAt!;
53+
if (!showCompleted && dueDate.isBefore(startOfToday)) {
54+
// Due date in the past → overdue
55+
categorized[TaskCategory.overdue]!.add(item);
56+
} else if (dueDate.isBefore(startOfTomorrow)) {
57+
categorized[TaskCategory.today]!.add(item);
58+
} else if (dueDate.isBefore(startOfDayAfterTomorrow)) {
59+
categorized[TaskCategory.tomorrow]!.add(item);
60+
} else {
61+
categorized[TaskCategory.later]!.add(item);
62+
}
63+
}
64+
}
65+
66+
return categorized;
67+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import 'dart:convert';
2+
import 'dart:io';
3+
4+
import 'package:flutter_test/flutter_test.dart';
5+
6+
import 'package:omi/backend/schema/gen/action_items_folders_wire.g.dart';
7+
import 'package:omi/pages/action_items/task_categorization.dart';
8+
import 'package:omi/providers/conversation_provider.dart';
9+
10+
/// Flutter conformance suite for the shared cross-platform parity contracts
11+
/// (contracts/parity/README.md). Runs the repo-root fixture vectors through the
12+
/// REAL production rules: task bucketing (categorizeTasks, this platform's
13+
/// separate_overdue model), local-day conversation keys
14+
/// (conversationLocalDayKey, the #10198 contract), and action item wire decode
15+
/// (GeneratedActionItemResponse.fromJson). Day-key cases execute only when the
16+
/// fixture covers the runner's zone offset at that instant; offset 0 is always
17+
/// present so UTC CI runs every case.
18+
void main() {
19+
final root = _repoRoot();
20+
21+
group('task due buckets (separate_overdue model)', () {
22+
final fixture = _fixture(root, 'task_due_buckets.json');
23+
const bucketByName = {
24+
'today': TaskCategory.today,
25+
'tomorrow': TaskCategory.tomorrow,
26+
'later': TaskCategory.later,
27+
'no_deadline': TaskCategory.noDeadline,
28+
'overdue': TaskCategory.overdue,
29+
};
30+
for (final raw in fixture['cases'] as List<dynamic>) {
31+
final c = raw as Map<String, dynamic>;
32+
test(c['name'] as String, () {
33+
final now = _localFromComponents(c['now'] as List<dynamic>);
34+
final due = c['due'] == null ? null : _localFromComponents(c['due'] as List<dynamic>);
35+
final created = c['created'] == null ? null : _localFromComponents(c['created'] as List<dynamic>);
36+
final item = _wireItem(due: due, created: created);
37+
38+
final categorized = categorizeTasks([item], false, now: now);
39+
final actual = categorized.entries.where((e) => e.value.contains(item)).map((e) => e.key).toList();
40+
final expectedName = (c['expected'] as Map<String, dynamic>)['separate_overdue'] as String;
41+
42+
expect(actual, [bucketByName[expectedName]!]);
43+
});
44+
}
45+
});
46+
47+
group('local day keys', () {
48+
final fixture = _fixture(root, 'day_keys.json');
49+
for (final raw in fixture['cases'] as List<dynamic>) {
50+
final c = raw as Map<String, dynamic>;
51+
final instant = DateTime.parse(c['utc'] as String);
52+
final offsetEast = instant.toLocal().timeZoneOffset.inMinutes;
53+
final expected = (c['expected_by_offset'] as Map<String, dynamic>)['$offsetEast'] as String?;
54+
test('${c['name']} (offset $offsetEast)', () {
55+
final key = conversationLocalDayKey(instant);
56+
expect('${key.year}-${_pad(key.month)}-${_pad(key.day)}', expected);
57+
}, skip: expected == null ? 'fixture does not cover this zone offset' : false);
58+
}
59+
});
60+
61+
group('action item wire decode (parity contract)', () {
62+
final fixture = _fixture(root, 'wire_action_item.json');
63+
for (final raw in fixture['cases'] as List<dynamic>) {
64+
final c = raw as Map<String, dynamic>;
65+
test(c['name'] as String, () {
66+
final byModel = c['expected_by_model'] as Map<String, dynamic>?;
67+
if (byModel != null) {
68+
// This client is the strict_decode model: a present-but-unparseable
69+
// due_at rejects the whole item (see the README divergence register).
70+
final strict = byModel['strict_decode'] as Map<String, dynamic>;
71+
expect(strict['parses'], isFalse);
72+
expect(
73+
() => GeneratedActionItemResponse.fromJson(c['payload'] as Map<String, dynamic>),
74+
throwsFormatException,
75+
);
76+
return;
77+
}
78+
final expected = c['expected'] as Map<String, dynamic>;
79+
final item = GeneratedActionItemResponse.fromJson(c['payload'] as Map<String, dynamic>);
80+
81+
expect(expected['parses'], isTrue);
82+
expect(item.description, expected['description']);
83+
expect(item.completed, expected['completed']);
84+
final dueUtc = expected['due_utc'] as String?;
85+
if (dueUtc == null) {
86+
expect(item.dueAt, isNull);
87+
} else {
88+
expect(item.dueAt?.millisecondsSinceEpoch, DateTime.parse(dueUtc).millisecondsSinceEpoch);
89+
}
90+
});
91+
}
92+
});
93+
}
94+
95+
/// Walk up from the package dir to the repo root (the dir holding
96+
/// contracts/parity), so the suite works from either the app dir or repo root.
97+
Directory _repoRoot() {
98+
var dir = Directory.current.absolute;
99+
for (var i = 0; i < 6; i++) {
100+
if (Directory('${dir.path}${Platform.pathSeparator}contracts${Platform.pathSeparator}parity').existsSync()) {
101+
return dir;
102+
}
103+
final parent = dir.parent;
104+
if (parent.path == dir.path) break;
105+
dir = parent;
106+
}
107+
throw StateError('contracts/parity not found above ${Directory.current.path}');
108+
}
109+
110+
Map<String, dynamic> _fixture(Directory root, String name) {
111+
final file = File(
112+
'${root.path}${Platform.pathSeparator}contracts${Platform.pathSeparator}parity'
113+
'${Platform.pathSeparator}$name',
114+
);
115+
return jsonDecode(file.readAsStringSync()) as Map<String, dynamic>;
116+
}
117+
118+
/// Fixture local-time components [year, month, day, hour?, minute?] in the
119+
/// runner's zone (the contracts are calendar rules, zone-independent).
120+
DateTime _localFromComponents(List<dynamic> c) =>
121+
DateTime(c[0] as int, c[1] as int, c[2] as int, c.length > 3 ? c[3] as int : 0, c.length > 4 ? c[4] as int : 0);
122+
123+
String _pad(int n) => n.toString().padLeft(2, '0');
124+
125+
/// Build the item through the production wire decode so bucket cases exercise
126+
/// the same path a backend response takes.
127+
GeneratedActionItemResponse _wireItem({DateTime? due, DateTime? created}) => GeneratedActionItemResponse.fromJson({
128+
'id': 'parity',
129+
'description': 'parity case',
130+
'completed': false,
131+
if (created != null) 'created_at': created.toUtc().toIso8601String(),
132+
if (due != null) 'due_at': due.toUtc().toIso8601String(),
133+
});

backend/models/action_item.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
"""Canonical action-item contracts and legacy compatibility projections."""
22

3-
from datetime import datetime
3+
from datetime import datetime, timezone
44
from enum import Enum
55
from typing import Any, Optional
66

7-
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, model_validator
7+
from pydantic import AwareDatetime, BaseModel, ConfigDict, Field, field_validator, model_validator
88

99
from models.task_intelligence import StableId
1010

@@ -237,6 +237,19 @@ def project_legacy_fields(cls, value: Any):
237237
data.setdefault('provenance', [])
238238
return data
239239

240+
@field_validator('due_at', 'created_at', 'updated_at', 'completed_at', 'export_date', mode='after')
241+
@classmethod
242+
def _naive_timestamps_are_utc(cls, value: Optional[datetime]) -> Optional[datetime]:
243+
# Firestore timestamps are UTC; a naive one leaking through serializes
244+
# without an offset, which Dart/JS decode as LOCAL wall time and Swift's
245+
# ISO8601 decoder rejects outright. Stamp UTC so every emitted timestamp
246+
# carries an explicit offset (contracts/parity/README.md). utcoffset()
247+
# rather than tzinfo: a tzinfo whose utcoffset() returns None is still
248+
# semantically naive and would serialize offsetless all the same.
249+
if value is not None and value.utcoffset() is None:
250+
return value.replace(tzinfo=timezone.utc)
251+
return value
252+
240253

241254
class ActionItemsResponse(BaseModel):
242255
action_items: list[ActionItemResponse]

0 commit comments

Comments
 (0)